Subscriptions
elements man payments/subscriptions Read as markdownA subscription is a Checkout Session in subscription mode with a recurring
inline price. Stripe bills the card every period and tells the app through the
webhook.
The Table
app/migrations/20260509120000-add-subscriptions.migration.sqlcreate table subscriptions ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), userId uuid not null unique references users (id), stripeSubscriptionId text not null unique, stripeCustomerId text not null, status text not null, currentPeriodEnd timestamptz, cancelAtPeriodEnd boolean not null default false );
status mirrors Stripe's: active, past_due, canceled and the rest. Gate
paid features on status in ('active', 'past_due'), and keep access until
currentPeriodEnd after a cancel.
Start a Subscription
Exampleif (testCheckout()) { return `/checkout/test/plan/${plan.id}`; } await ensureWebhook(); let checkout = await stripe().checkout.sessions.create({ mode: "subscription", customer_email: email, client_reference_id: userId, line_items: [{ quantity: 1, price_data: { currency: "usd", unit_amount: plan.priceCents, recurring: { interval: "month" }, product_data: { name: plan.name }, }, }], success_url: `${getAppUrl()}/checkout/return?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${getAppUrl()}/pricing`, });
Without a key, the test checkout (payments/checkout) takes the plan instead:
its Pay button inserts the subscriptions row with status active,
stripeSubscriptionId and stripeCustomerId set to test_<userId>, and
currentPeriodEnd a month out. Gate cancelSubscription and every other
Stripe call on testCheckout() the same way, updating the row directly.
Sync From Stripe
One function copies a subscription's state into the row. The return page, the
webhook and the cancel rpc all call it. Only a call that knows the user creates
the row; a webhook for a subscription the app has not seen yet only updates,
and the return page or checkout.session.completed creates it.
app/shared/subscriptions.tsimport Stripe from "stripe"; import { sql } from "@elements/app"; import { stripe } from "#app/shared/stripe"; export async function syncSubscription(subscriptionId: string, userId?: string) { let sub: Stripe.Subscription = await stripe().subscriptions.retrieve(subscriptionId); let periodEnd = sub.items.data[0]?.current_period_end; let status = sub.status; let end = periodEnd ? new Date(periodEnd * 1000) : null; if (!userId) { sql(` update subscriptions set status = ${status}, currentPeriodEnd = ${end}, cancelAtPeriodEnd = ${sub.cancel_at_period_end} where stripeSubscriptionId = ${sub.id} `); return; } sql(` insert into subscriptions ( userId, stripeSubscriptionId, stripeCustomerId, status, currentPeriodEnd, cancelAtPeriodEnd ) values ( ${userId}, ${sub.id}, ${sub.customer as string}, ${status}, ${end}, ${sub.cancel_at_period_end} ) on conflict (stripeSubscriptionId) do update set status = excluded.status, currentPeriodEnd = excluded.currentPeriodEnd, cancelAtPeriodEnd = excluded.cancelAtPeriodEnd `); }
A session in subscription mode carries subscription, and its
client_reference_id is the user, not an order. Branch on it at the top of
fulfillCheckout (payments/checkout), right after the paid check, so both
the return page and checkout.session.completed create the row:
app/shared/checkout.tsif (checkout.mode === "subscription") { await syncSubscription(checkout.subscription as string, checkout.client_reference_id); return true; }
In the webhook route, add:
app/pages/stripe-webhook/index.tscase "customer.subscription.updated": case "customer.subscription.deleted": await syncSubscription(event.data.object.id); break; case "invoice.paid": // A renewal. Send a receipt here if the app sends its own. break;
Cancel From the App
Cancel through the API, so the owner configures nothing in the dashboard:
Cancel rpc/** @rpc */ export async function cancelSubscription() { let userId = session.getOrThrow("userId"); let row = sql<{ stripeSubscriptionId: string }>(` select stripeSubscriptionId from subscriptions where userId = ${userId} `).firstOrThrow(); await stripe().subscriptions.update(row.stripeSubscriptionId, { cancel_at_period_end: true }); await syncSubscription(row.stripeSubscriptionId); }
Resume is the same call with cancel_at_period_end: false. The rpc syncs
right away, so the page updates without waiting for the webhook. In
development the row tracks every change the app makes, and stripe listen
adds renewals from Stripe.
Related
payments/webhook: the route these cases go in.