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
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
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`,
});
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:
if (checkout.mode === "subscription") {
await syncSubscription(checkout.subscription as string, checkout.client_reference_id);
return true;
}
In the webhook route, add:
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:
/** @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, without stripe listen, renewals never arrive; the row is still
right for everything the app itself does.
Related
payments/webhook: the route these cases go in.