Manual Payments Subscriptions

Subscriptions

elements man payments/subscriptions Read as markdown

A 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.sql
create 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

Example
if (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.ts
import 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.ts
if (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.ts
case "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.