feat(billing): track scheduled cancellation + surface it in Settings
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m14s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m14s
Stripe portal "cancel" schedules a cancel_at timestamp while keeping the sub active until period end. Without persisting that state the UI kept saying "renews on X" even though the user had already canceled. New `users.plan_cancels_at` column populated from the subscription webhook (handles both `cancel_at` and legacy `cancel_at_period_end`). Settings now shows a banner "Subscription scheduled to end on X" and flips the footer label from "renews" to "ends" while the cancellation window is open. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4719d25f8f
commit
641ec629f5
1
server/src/db/migrations/0004_plan_cancels_at.sql
Normal file
1
server/src/db/migrations/0004_plan_cancels_at.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "plan_cancels_at" timestamp with time zone;
|
||||||
583
server/src/db/migrations/meta/0004_snapshot.json
Normal file
583
server/src/db/migrations/meta/0004_snapshot.json
Normal file
@ -0,0 +1,583 @@
|
|||||||
|
{
|
||||||
|
"id": "43434aee-a97e-4239-8c38-4b518e2a8175",
|
||||||
|
"prevId": "715a57a4-85a8-4321-82bb-45b7dadd863e",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.magic_links": {
|
||||||
|
"name": "magic_links",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"used_at": {
|
||||||
|
"name": "used_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"magic_links_token_hash_unique": {
|
||||||
|
"name": "magic_links_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"magic_links_email_idx": {
|
||||||
|
"name": "magic_links_email_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ip_hash": {
|
||||||
|
"name": "ip_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_token_hash_unique": {
|
||||||
|
"name": "sessions_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"sessions_user_idx": {
|
||||||
|
"name": "sessions_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.transfers": {
|
||||||
|
"name": "transfers",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"storage_key": {
|
||||||
|
"name": "storage_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sender_user_id": {
|
||||||
|
"name": "sender_user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sender_device_id": {
|
||||||
|
"name": "sender_device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"recipient_user_id": {
|
||||||
|
"name": "recipient_user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"recipient_email_hash": {
|
||||||
|
"name": "recipient_email_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"encrypted_metadata": {
|
||||||
|
"name": "encrypted_metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"max_downloads": {
|
||||||
|
"name": "max_downloads",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"download_count": {
|
||||||
|
"name": "download_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"first_download_at": {
|
||||||
|
"name": "first_download_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"transfers_sender_idx": {
|
||||||
|
"name": "transfers_sender_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sender_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"transfers_recipient_idx": {
|
||||||
|
"name": "transfers_recipient_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "recipient_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"transfers_expires_idx": {
|
||||||
|
"name": "transfers_expires_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "expires_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"transfers_sender_user_id_users_id_fk": {
|
||||||
|
"name": "transfers_sender_user_id_users_id_fk",
|
||||||
|
"tableFrom": "transfers",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"sender_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"transfers_recipient_user_id_users_id_fk": {
|
||||||
|
"name": "transfers_recipient_user_id_users_id_fk",
|
||||||
|
"tableFrom": "transfers",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"recipient_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_devices": {
|
||||||
|
"name": "user_devices",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"linked_at": {
|
||||||
|
"name": "linked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_devices_user_device_unique": {
|
||||||
|
"name": "user_devices_user_device_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "device_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_devices_user_id_users_id_fk": {
|
||||||
|
"name": "user_devices_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_devices",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"stripe_customer_id": {
|
||||||
|
"name": "stripe_customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripe_subscription_id": {
|
||||||
|
"name": "stripe_subscription_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan_status": {
|
||||||
|
"name": "plan_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan_expires_at": {
|
||||||
|
"name": "plan_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan_cancels_at": {
|
||||||
|
"name": "plan_cancels_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,13 @@
|
|||||||
"when": 1776684213404,
|
"when": 1776684213404,
|
||||||
"tag": "0003_stripe_subscription",
|
"tag": "0003_stripe_subscription",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776686231184,
|
||||||
|
"tag": "0004_plan_cancels_at",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -10,6 +10,7 @@ export const users = pgTable(
|
|||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
planStatus: text("plan_status"),
|
planStatus: text("plan_status"),
|
||||||
planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }),
|
planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }),
|
||||||
|
planCancelsAt: timestamp("plan_cancels_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,7 @@ meRoutes.get("/me", async (c) => {
|
|||||||
plan: user.plan,
|
plan: user.plan,
|
||||||
planStatus: user.planStatus,
|
planStatus: user.planStatus,
|
||||||
planExpiresAt: user.planExpiresAt,
|
planExpiresAt: user.planExpiresAt,
|
||||||
|
planCancelsAt: user.planCancelsAt,
|
||||||
hasStripeCustomer: !!user.stripeCustomerId,
|
hasStripeCustomer: !!user.stripeCustomerId,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -76,12 +76,19 @@ async function applySubscription(userId: string, sub: Stripe.Subscription): Prom
|
|||||||
(sub as unknown as { current_period_end?: number }).current_period_end ??
|
(sub as unknown as { current_period_end?: number }).current_period_end ??
|
||||||
0;
|
0;
|
||||||
const planExpiresAt = periodEnd ? new Date(periodEnd * 1000) : null;
|
const planExpiresAt = periodEnd ? new Date(periodEnd * 1000) : null;
|
||||||
|
// Cancellation scheduling: portal uses `cancel_at` (explicit timestamp);
|
||||||
|
// `cancel_at_period_end` is the legacy flag. Either means "stop renewing".
|
||||||
|
const cancelAtUnix =
|
||||||
|
sub.cancel_at ??
|
||||||
|
(sub.cancel_at_period_end ? periodEnd : null);
|
||||||
|
const planCancelsAt = cancelAtUnix ? new Date(cancelAtUnix * 1000) : null;
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
plan,
|
plan,
|
||||||
planStatus: sub.status,
|
planStatus: sub.status,
|
||||||
planExpiresAt,
|
planExpiresAt,
|
||||||
|
planCancelsAt,
|
||||||
stripeSubscriptionId: sub.id,
|
stripeSubscriptionId: sub.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface ApiUser {
|
|||||||
plan: "free" | "pro";
|
plan: "free" | "pro";
|
||||||
planStatus: string | null;
|
planStatus: string | null;
|
||||||
planExpiresAt: string | null;
|
planExpiresAt: string | null;
|
||||||
|
planCancelsAt: string | null;
|
||||||
hasStripeCustomer: boolean;
|
hasStripeCustomer: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,10 +235,12 @@ function PlanSection() {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const isPro = user.plan === "pro";
|
const isPro = user.plan === "pro";
|
||||||
const renewLabel = user.planExpiresAt
|
const cancelDate = user.planCancelsAt ? new Date(user.planCancelsAt) : null;
|
||||||
? new Date(user.planExpiresAt).toLocaleDateString()
|
const renewDate = user.planExpiresAt ? new Date(user.planExpiresAt) : null;
|
||||||
: null;
|
const isCanceling = isPro && cancelDate !== null;
|
||||||
const statusLabel = user.planStatus === "canceled"
|
const dateToShow = cancelDate ?? renewDate;
|
||||||
|
const dateLabel = dateToShow ? dateToShow.toLocaleDateString() : null;
|
||||||
|
const statusLabel = isCanceling || user.planStatus === "canceled"
|
||||||
? "ends"
|
? "ends"
|
||||||
: "renews";
|
: "renews";
|
||||||
|
|
||||||
@ -277,12 +279,17 @@ function PlanSection() {
|
|||||||
<div className="paper-panel px-5 py-4">
|
<div className="paper-panel px-5 py-4">
|
||||||
{isPro ? (
|
{isPro ? (
|
||||||
<>
|
<>
|
||||||
|
{isCanceling && dateLabel && (
|
||||||
|
<div className="mb-3 px-3 py-2 border border-fail/40 bg-signal-quiet text-xs text-ink rounded-sm">
|
||||||
|
Subscription scheduled to end on {dateLabel}. Renew from the portal to keep Pro.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-ink">
|
<p className="text-sm text-ink">
|
||||||
20 GB per transfer · up to 90 days · 100 downloads per link.
|
20 GB per transfer · up to 90 days · 100 downloads per link.
|
||||||
</p>
|
</p>
|
||||||
{renewLabel && (
|
{dateLabel && (
|
||||||
<p className="mt-1 text-xs text-ink-muted font-mono">
|
<p className="mt-1 text-xs text-ink-muted font-mono">
|
||||||
{statusLabel} {renewLabel}
|
{statusLabel} {dateLabel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user