Enable subscriptions

Add subscriptions to your SvelteKit app. Scaffolds product and price sync, a checkout flow, and the server wiring required to go from zero to a working Stripe integration without hand-rolling the plumbing.

$ vela enable subscriptions
src/lib/workflows/sync-stripe-subscriptions.ts
import stripe from "$lib/stripe";
import { getAdmin, ow } from "$lib/server/workflows";
import { subscriptionRecordFields } from "../../routes/webhooks/stripe/handlers/subscription/shared";

/**
 * Brings stripe_subscriptions in line with Stripe, all of them, so a webhook
 * that never arrived is corrected within ten minutes.
 */
export const syncStripeSubscriptions = ow.defineWorkflow(
  { name: "sync-stripe-subscriptions", retryPolicy: { maximumAttempts: 3 } },
  async ({ step }) => {
    const subscriptions = await step.run({ name: "list" }, async () => {
      const rows = [];
      for await (const subscription of stripe.subscriptions.list({
        status: "all",
        limit: 100,
      })) {
        const fields = subscriptionRecordFields(subscription);
        if (fields) rows.push(fields);
      }
      return rows;
    });

    return step.run({ name: "upsert" }, async () => {
      const admin = await getAdmin();
      let synced = 0;
      let skipped = 0;
      for (const fields of subscriptions) {
        let customer;
        try {
          customer = await admin
            .collection("stripe_customers")
            .getOne(fields.customer);
        } catch {
          // A customer this app does not know about.
          skipped += 1;
          continue;
        }
        try {
          await admin
            .collection("stripe_subscriptions")
            .update(fields.id, fields);
        } catch {
          await admin
            .collection("stripe_subscriptions")
            .create({ ...fields, user: customer.user });
        }
        synced += 1;
      }
      return { synced, skipped };
    });
  },
);

export const cron = "*/10 * * * *";
src/routes/webhooks/stripe/handlers/subscription/created.ts
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";

/**
 * Handles customer.subscription.created event
 */
export async function handleSubscriptionCreated(
  subscription: Stripe.Subscription,
  locals: App.Locals,
) {
  const fields = subscriptionRecordFields(subscription);
  if (!fields) return;

  const customer = await locals.admin
    .collection("stripe_customers")
    .getOne(fields.customer);

  await locals.admin.collection("stripe_subscriptions").create({
    ...fields,
    user: customer.user,
  });
}
src/routes/webhooks/stripe/handlers/subscription/deleted.ts
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";

/**
 * Handles customer.subscription.deleted event
 */
export async function handleSubscriptionDeleted(
  subscription: Stripe.Subscription,
  locals: App.Locals,
) {
  const fields = subscriptionRecordFields(subscription);
  if (!fields) return;

  try {
    await locals.admin
      .collection("stripe_subscriptions")
      .update(subscription.id, {
        ...fields,
        status: "canceled",
        canceled_at: fields.canceled_at ?? new Date().toISOString(),
      });
  } catch {
    // Row may not exist; ignore.
    console.log(`Subscription ${subscription.id} not found for deletion`);
  }
}
src/routes/webhooks/stripe/handlers/subscription/shared.ts
import Stripe from "stripe";

function unixToIso(unix: number | null | undefined): string | null {
  if (!unix) return null;
  return new Date(unix * 1000).toISOString();
}

/**
 * Extracts the subset of Stripe.Subscription fields that map to a
 * stripe_subscriptions row. Returns null if the subscription has no
 * price (shouldn't happen, but guard).
 *
 * `current_period_start`/`current_period_end` live on the subscription item
 * in API versions >= 2025-03-31 — read from there with a fallback to the
 * subscription-level field for older events.
 */
export function subscriptionRecordFields(subscription: Stripe.Subscription) {
  const item = subscription.items.data[0];
  const priceId = item?.price.id;
  if (!priceId) {
    console.error(`Subscription ${subscription.id} has no price`);
    return null;
  }

  const customerId =
    typeof subscription.customer === "string"
      ? subscription.customer
      : subscription.customer.id;

  const itemPeriodStart = (item as unknown as { current_period_start?: number })
    .current_period_start;
  const itemPeriodEnd = (item as unknown as { current_period_end?: number })
    .current_period_end;
  const subPeriodStart = (
    subscription as unknown as { current_period_start?: number }
  ).current_period_start;
  const subPeriodEnd = (
    subscription as unknown as { current_period_end?: number }
  ).current_period_end;

  return {
    id: subscription.id,
    customer: customerId,
    price: priceId,
    status: subscription.status,
    current_period_start: unixToIso(itemPeriodStart ?? subPeriodStart),
    current_period_end: unixToIso(itemPeriodEnd ?? subPeriodEnd),
    cancel_at_period_end: subscription.cancel_at_period_end,
    canceled_at: unixToIso(subscription.canceled_at),
  };
}
src/routes/webhooks/stripe/handlers/subscription/updated.ts
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";

/**
 * Handles customer.subscription.updated event
 */
export async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription,
  locals: App.Locals,
) {
  const fields = subscriptionRecordFields(subscription);
  if (!fields) return;

  try {
    await locals.admin
      .collection("stripe_subscriptions")
      .update(subscription.id, fields);
  } catch {
    // Row may not exist if the created event was missed; upsert instead.
    const customer = await locals.admin
      .collection("stripe_customers")
      .getOne(fields.customer);

    await locals.admin.collection("stripe_subscriptions").create({
      ...fields,
      user: customer.user,
    });
  }
}