Guides

Cookbook

Recipe 1 · Stripe payment lifecycle

A payment goes pending → authorized → captured, or failed at any point, or refunded from captured. Stateledger holds the source of truth so a duplicated Stripe webhook can’t double-capture.

typescript
export const paymentMachine = defineMachine({
  name: "payment",
  states: ["pending", "authorized", "captured", "refunded", "failed"] as const,
  initialState: "pending",
  transitions: [
    { from: "pending",    to: "authorized" },
    { from: "pending",    to: "failed" },
    { from: "authorized", to: "captured" },
    { from: "authorized", to: "failed" },
    { from: "captured",   to: "refunded" },
  ] as const,
});

// Inside your Stripe webhook handler:
export async function onStripeEvent(event: Stripe.Event) {
  await prisma.$transaction(async (tx) => {
    const payment = paymentMachine.for(event.data.object.metadata.paymentId, {
      adapter: createPrismaAdapter(tx),
      actor: "stripe:webhook",
    });

    switch (event.type) {
      case "payment_intent.succeeded":
        await payment.transitionTo("captured", { metadata: { intent: event.data.object.id } });
        break;
      case "payment_intent.payment_failed":
        await payment.transitionTo("failed", { metadata: { reason: event.data.object.last_payment_error?.message } });
        break;
      case "charge.refunded":
        await payment.transitionTo("refunded", { metadata: { refundId: event.data.object.refunds?.data[0]?.id } });
        break;
    }
  });
}

Duplicated webhooks are harmless: the second event tries to move an already-captured payment to captured, and stateledger throws InvalidTransition. Handle it and return 200 to Stripe.

Recipe 2 · Subscription dunning

Recurring subscriptions cycle through trial → active → past_due (on failed renewal) → cancelled. Each dunning attempt writes an audit row.

typescript
export const subscriptionMachine = defineMachine({
  name: "subscription",
  states: ["trialing", "active", "past_due", "cancelled"] as const,
  initialState: "trialing",
  transitions: [
    { from: "trialing", to: "active" },
    { from: "trialing", to: "cancelled" },
    { from: "active",   to: "past_due" },
    { from: "active",   to: "cancelled" },
    { from: "past_due", to: "active" },     // successful retry
    { from: "past_due", to: "cancelled" },  // grace period expired
  ] as const,
});

// Cron worker: attempt renewal for every past_due subscription
export async function retryPastDue() {
  const subs = await prisma.subscription.findMany({ where: { state: "past_due" } });

  for (const sub of subs) {
    await prisma.$transaction(async (tx) => {
      const machine = subscriptionMachine.for(sub.id, {
        adapter: createPrismaAdapter(tx),
        actor: "dunning:worker",
      });

      const result = await chargeStripe(sub.customerId, sub.amount);
      if (result.ok) {
        await machine.transitionTo("active", { metadata: { chargeId: result.chargeId } });
      } else if (Date.now() - sub.pastDueSince > GRACE_PERIOD_MS) {
        await machine.transitionTo("cancelled", { metadata: { reason: "grace_expired" } });
      }
      // else: leave in past_due for next attempt
    });
  }
}

Recipe 3 · Order fulfilment

E-commerce orders move through a longer pipeline. Each stage typically has a different actor (system vs warehouse vs carrier).

typescript
export const orderMachine = defineMachine({
  name: "order",
  states: ["cart", "paid", "picked", "packed", "shipped", "delivered", "returned"] as const,
  initialState: "cart",
  transitions: [
    { from: "cart",    to: "paid" },
    { from: "paid",    to: "picked" },
    { from: "picked",  to: "packed" },
    { from: "packed",  to: "shipped" },
    { from: "shipped", to: "delivered" },
    { from: "delivered", to: "returned" },
  ] as const,
});

// Warehouse endpoint:
export async function markPicked(orderId: string, warehouseUser: string) {
  return prisma.$transaction(async (tx) => {
    const order = orderMachine.for(orderId, {
      adapter: createPrismaAdapter(tx),
      actor: warehouseUser,
    });
    return order.transitionTo("picked", {
      metadata: { pickedAt: new Date().toISOString() },
    });
  });
}

Recipe 4 · KYC & onboarding

Regulated flows need airtight audit trails: who approved this user, when, based on what evidence. Stateledger records all of it.

typescript
export const kycMachine = defineMachine({
  name: "kyc",
  states: ["unverified", "pending", "approved", "rejected", "suspended"] as const,
  initialState: "unverified",
  transitions: [
    { from: "unverified", to: "pending" },
    { from: "pending",    to: "approved" },
    { from: "pending",    to: "rejected" },
    { from: "approved",   to: "suspended" },
    { from: "suspended",  to: "approved" },
  ] as const,
});

// Compliance officer approves:
await prisma.$transaction(async (tx) => {
  const kyc = kycMachine.for(userId, {
    adapter: createPrismaAdapter(tx),
    actor: `officer:${officerId}`,
  });
  await kyc.transitionTo("approved", {
    metadata: {
      documentIds: ["dl-01", "utility-02"],
      reviewedAt: new Date().toISOString(),
      notes: "Address matches DL. Approved.",
    },
  });
});

Recipe 5 · Content moderation queue

Two moderators shouldn’t accidentally act on the same submission. The advisory lock in stateledger prevents this without any coordination code.

typescript
export const moderationMachine = defineMachine({
  name: "content_moderation",
  states: ["submitted", "in_review", "published", "flagged", "removed"] as const,
  initialState: "submitted",
  transitions: [
    { from: "submitted", to: "in_review" },
    { from: "in_review", to: "published" },
    { from: "in_review", to: "flagged" },
    { from: "flagged",   to: "removed" },
    { from: "flagged",   to: "published" }, // re-approved on appeal
  ] as const,
});

// If moderator A and B both hit "start review" at the same instant,
// one succeeds; the other gets InvalidTransition when it retries.
export async function startReview(itemId: string, moderator: string) {
  return prisma.$transaction(async (tx) => {
    const item = moderationMachine.for(itemId, {
      adapter: createPrismaAdapter(tx),
      actor: moderator,
    });
    return item.transitionTo("in_review");
  });
}