Logo VelaStack

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/routes/api/stripe/sync/subscriptions/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";
import { subscriptionRecordFields } from "../../../../webhooks/stripe/handlers/subscription/shared";

export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${INTERNAL_JOB_SECRET}`) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  const subscriptions = await stripe.subscriptions.list({
    status: "all",
    limit: 100,
  });

  for (const subscription of subscriptions.data) {
    const fields = subscriptionRecordFields(subscription);
    if (!fields) continue;

    let customer;
    try {
      customer = await locals.admin
        .collection("stripe_customers")
        .getOne(fields.customer);
    } catch {
      console.log(
        `Skipping subscription ${subscription.id}: customer ${fields.customer} not found locally`,
      );
      continue;
    }

    try {
      await locals.admin
        .collection("stripe_subscriptions")
        .update(subscription.id, fields);
    } catch {
      await locals.admin.collection("stripe_subscriptions").create({
        ...fields,
        user: customer.user,
      });
    }
  }

  console.log("Subscriptions synced");
  return json({ message: "Subscriptions synced" });
};
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,
    });
  }
}