Compare commits

..

No commits in common. "641ec629f5728ce3c15989f46c4c630da7cfef7b" and "629db7276438a8d34a04390126e4bad4a5d5b00d" have entirely different histories.

9 changed files with 7 additions and 615 deletions

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"scripts": { "scripts": {
"dev": "tsx watch --env-file=.env src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",

View File

@ -1 +0,0 @@
ALTER TABLE "users" ADD COLUMN "plan_cancels_at" timestamp with time zone;

View File

@ -1,583 +0,0 @@
{
"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": {}
}
}

View File

@ -29,13 +29,6 @@
"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
} }
] ]
} }

View File

@ -10,7 +10,6 @@ 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(),
}, },

View File

@ -28,7 +28,6 @@ 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,
}, },

View File

@ -76,19 +76,12 @@ 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(),
}) })

View File

@ -4,7 +4,6 @@ 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;
} }

View File

@ -235,12 +235,10 @@ function PlanSection() {
if (!user) return null; if (!user) return null;
const isPro = user.plan === "pro"; const isPro = user.plan === "pro";
const cancelDate = user.planCancelsAt ? new Date(user.planCancelsAt) : null; const renewLabel = user.planExpiresAt
const renewDate = user.planExpiresAt ? new Date(user.planExpiresAt) : null; ? new Date(user.planExpiresAt).toLocaleDateString()
const isCanceling = isPro && cancelDate !== null; : null;
const dateToShow = cancelDate ?? renewDate; const statusLabel = user.planStatus === "canceled"
const dateLabel = dateToShow ? dateToShow.toLocaleDateString() : null;
const statusLabel = isCanceling || user.planStatus === "canceled"
? "ends" ? "ends"
: "renews"; : "renews";
@ -279,17 +277,12 @@ 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>
{dateLabel && ( {renewLabel && (
<p className="mt-1 text-xs text-ink-muted font-mono"> <p className="mt-1 text-xs text-ink-muted font-mono">
{statusLabel} {dateLabel} {statusLabel} {renewLabel}
</p> </p>
)} )}
<button <button