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

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.