diff --git a/server/src/db/migrations/0004_plan_cancels_at.sql b/server/src/db/migrations/0004_plan_cancels_at.sql new file mode 100644 index 0000000..6a52686 --- /dev/null +++ b/server/src/db/migrations/0004_plan_cancels_at.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "plan_cancels_at" timestamp with time zone; \ No newline at end of file diff --git a/server/src/db/migrations/meta/0004_snapshot.json b/server/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..80af4bc --- /dev/null +++ b/server/src/db/migrations/meta/0004_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 1f8ccf0..fbc2f47 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1776684213404, "tag": "0003_stripe_subscription", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1776686231184, + "tag": "0004_plan_cancels_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e11bea6..f5e2857 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -10,6 +10,7 @@ export const users = pgTable( stripeSubscriptionId: text("stripe_subscription_id"), planStatus: text("plan_status"), planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }), + planCancelsAt: timestamp("plan_cancels_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/server/src/http/me.ts b/server/src/http/me.ts index 84ee495..fad50b2 100644 --- a/server/src/http/me.ts +++ b/server/src/http/me.ts @@ -28,6 +28,7 @@ meRoutes.get("/me", async (c) => { plan: user.plan, planStatus: user.planStatus, planExpiresAt: user.planExpiresAt, + planCancelsAt: user.planCancelsAt, hasStripeCustomer: !!user.stripeCustomerId, createdAt: user.createdAt, }, diff --git a/server/src/http/webhook.ts b/server/src/http/webhook.ts index 785c8b5..088a9a1 100644 --- a/server/src/http/webhook.ts +++ b/server/src/http/webhook.ts @@ -76,12 +76,19 @@ async function applySubscription(userId: string, sub: Stripe.Subscription): Prom (sub as unknown as { current_period_end?: number }).current_period_end ?? 0; 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 .update(users) .set({ plan, planStatus: sub.status, planExpiresAt, + planCancelsAt, stripeSubscriptionId: sub.id, updatedAt: new Date(), }) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 85f4341..9bd1ba3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,6 +4,7 @@ export interface ApiUser { plan: "free" | "pro"; planStatus: string | null; planExpiresAt: string | null; + planCancelsAt: string | null; hasStripeCustomer: boolean; createdAt: string; } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7c0d98e..5e33718 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -235,10 +235,12 @@ function PlanSection() { if (!user) return null; const isPro = user.plan === "pro"; - const renewLabel = user.planExpiresAt - ? new Date(user.planExpiresAt).toLocaleDateString() - : null; - const statusLabel = user.planStatus === "canceled" + const cancelDate = user.planCancelsAt ? new Date(user.planCancelsAt) : null; + const renewDate = user.planExpiresAt ? new Date(user.planExpiresAt) : null; + const isCanceling = isPro && cancelDate !== null; + const dateToShow = cancelDate ?? renewDate; + const dateLabel = dateToShow ? dateToShow.toLocaleDateString() : null; + const statusLabel = isCanceling || user.planStatus === "canceled" ? "ends" : "renews"; @@ -277,12 +279,17 @@ function PlanSection() {
20 GB per transfer · up to 90 days · 100 downloads per link.
- {renewLabel && ( + {dateLabel && (- {statusLabel} {renewLabel} + {statusLabel} {dateLabel}
)}