# One-off Checkout

Charging once for one row — an order, a ticket, a report — is a **recipe**, not a
Quickback primitive. It is two halves that already exist:

1. An **action** creates a Stripe Checkout Session via
   [`services.stripe`](/platform/payments#servicesstripe), carrying the record's
   id in `metadata`.
2. A **`stripe:checkout.session.completed` handler** on the
   [inbound webhook surface](/platform/webhooks/inbound) reads that id back and
   marks the record paid.

`metadata` is the whole link between them. Stripe returns it verbatim on the
event, so the handler never has to guess which row a payment belongs to.

There is deliberately no `definePayment()`. If this recipe turns out to have a
hole in real use, that is the trigger to build one.

## Configuration

`webhooksBinding` is what turns on the inbound surface — and with it,
`services.stripe`:

```ts title="quickback/quickback.config.ts"
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";

export default defineConfig({
  name: "storefront",
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1", {
      binding: "DB",
      // Turns on inbound webhooks — and emits services.stripe.
      webhooksBinding: "WEBHOOKS_DB",
    }),
    auth: defineAuth("better-auth", {
      emailAndPassword: { enabled: true },
    }),
  },
});
```

Set both secrets — see [the dev loop](/platform/payments/dev-loop) for test vs
live values:

```bash
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
```

## The record

Nothing about this table is payment machinery — it is your domain row plus three
columns recording what Stripe did to it.

```ts title="quickback/features/orders/orders.ts"
import { q, defineTable } from "@quickback/compiler";

export const orders = q.table("orders", {
  id:              q.id(),
  description:     q.text().required(),
  amountCents:     q.int().required(),
  currency:        q.text().default("usd").required(),
  status:          q.text().default("unpaid").required(),
  stripeSessionId: q.text().optional(),
  paidAt:          q.timestamp().optional(),
  organizationId:  q.scope("organization"),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(orders, {
  read: { access: { roles: ["AUTHENTICATED"] } },
  crud: {
    create: { access: { roles: ["AUTHENTICATED"] } },
    // Stripe is the only writer of `status: "paid"`. Clients never mark
    // their own orders paid, so ordinary update/delete stay admin-only.
    update: { access: { roles: ["admin"] } },
    delete: { access: { roles: ["admin"] } },
  },
});
```

`status` starts at `unpaid` and only the webhook handler moves it. That is the
entire trust model: the client is told where to pay, and Stripe — not the
client — reports back that it happened.

## The action and the handler

Both halves live in one action file. The handler is registered at **module
scope**, which is what puts it on the queue consumer's module graph: feature
action files are imported by the feature's routes, the routes are imported by
the worker entry, and that same entry exports the `queue()` consumer.

```ts title="quickback/features/orders/actions/startCheckout.ts"
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { defineAction, orders } from "../.quickback/define-action";
import { onWebhookEvent } from "../../../lib/webhooks";

export default defineAction({
  description: "Create a Stripe Checkout Session for this order.",
  input: z.object({
    successUrl: z.string().url(),
    cancelUrl: z.string().url(),
  }),
  access: { roles: ["AUTHENTICATED"] },
  async execute({ services, record, input }) {
    if (record.status === "paid") {
      throw new Error("Order is already paid");
    }

    const session = await services.stripe.checkout.sessions.create(
      {
        mode: "payment",
        success_url: input.successUrl,
        cancel_url: input.cancelUrl,
        line_items: [
          {
            quantity: 1,
            price_data: {
              currency: record.currency,
              unit_amount: record.amountCents,
              product_data: { name: record.description },
            },
          },
        ],
        // The only link back to the row. Stripe returns it verbatim.
        metadata: { orderId: record.id },
      },
      // A retried click reuses the session instead of opening a second one.
      { idempotencyKey: `order-checkout-${record.id}` },
    );

    return { checkoutUrl: session.url };
  },
});

// ── Fulfilment ────────────────────────────────────────────────────────────
onWebhookEvent("stripe:checkout.session.completed", async (ctx) => {
  // Through `unknown`: ctx.data is a Record<string, unknown>, which does not
  // sufficiently overlap a Stripe resource type for a one-step cast.
  const session = ctx.data as unknown as import("stripe").Stripe.Checkout.Session;
  const orderId = session.metadata?.orderId;

  // A Checkout Session this recipe did not create. Not an error.
  if (!orderId) return;

  // `completed` is not `paid`: delayed-notification methods complete the
  // session unpaid and settle later on async_payment_succeeded.
  if (session.payment_status !== "paid") return;

  const db = drizzle(ctx.env.DB);
  const marked = await db
    .update(orders)
    // `q.timestamp()` is a text column on D1/SQLite — write the ISO string.
    .set({
      status: "paid",
      stripeSessionId: session.id,
      paidAt: new Date().toISOString(),
    })
    .where(and(eq(orders.id, orderId), eq(orders.status, "unpaid")))
    .returning({ id: orders.id });

  if (marked.length === 0) {
    console.warn(
      `stripe: no unpaid order ${orderId} for session ${session.id} — ignoring`,
    );
  }
});
```

The action returns a URL; redirect the browser to it. Everything after that
happens on Stripe's domain, and the next thing your Worker hears is the webhook.

> **A webhook handler has no caller, so it has no scoped `db`.** Actions get a
> security-filtered handle that applies the org/owner firewall from the request
> principal ([Scoped DB](/define/actions/scoped-db)). A queued event has no
> principal to scope to, so the handler builds a plain Drizzle client off
> `ctx.env` instead — exactly as the bundled subscription handlers do. That
> client is **unfiltered**: the `where` clause is the only thing keeping the
> write on one row, which is why it matches on the id Stripe echoed back and on
> `status = "unpaid"`.


## Idempotency is not optional

Inbound delivery is **at-least-once**. Deduplication on `(provider, externalId)`
stops Stripe redelivering an event you already accepted, but it does not stop a
*retry*: if any handler registered for a message throws, the queue retries the
whole message and **every** handler for that event runs again.

So the handler must converge, not accumulate. Two properties do it here:

| Property | What it buys |
|----------|--------------|
| `where status = "unpaid"` | The second run updates zero rows. Marking paid twice is a no-op, not a double state change. |
| `.returning()` + a zero-row check | The no-op is *observable*, so "already handled" is distinguishable from "never matched". |

Write the same way for any handler you add: match on the id the provider echoed
back, guard the transition on the state you expect to leave, and make a re-run
land on nothing.

The `idempotencyKey` on the outbound side is the mirror image — it stops a
double-clicked action from opening two Checkout Sessions for one order.

## The money rule

**The queue handler does state marking. It does not move money.**

Marking an order paid is *state propagation*: Stripe already took the money, and
the handler is recording a fact that is true whether or not the Worker is
healthy. Async, retried, eventually-consistent — all correct.

Posting to a ledger is not that. A general-ledger entry must succeed or fail
with the operation that caused it, in a transaction the caller can see the
result of. A queue handler has no caller, no shared transaction, and a retry
policy that will happily run your posting logic a second time.

| Belongs in the handler | Belongs in an action |
|------------------------|----------------------|
| `status → "paid"`, `paidAt`, `stripeSessionId` | Posting double-entry GL rows |
| Enqueuing a receipt email | Debiting a wallet or credit balance |
| Invalidating a cache | Anything whose failure must fail the caller's request |

If fulfilment must post to a ledger, the handler marks the record paid and the
ledger posting happens in an action — invoked by the operation that needs it,
synchronously, where a failure is somebody's 500 rather than a silent retry.

## Failure modes

Handle these explicitly; none of them should be a guess.

| Situation | Correct response |
|-----------|------------------|
| No `metadata.orderId` | Return quietly. The endpoint receives every Stripe event for the account, including ones this recipe never created. |
| `orderId` present, no matching row | **Log loudly and stop.** Never fall back to matching on amount, email, or customer — that is how one customer's payment marks another customer's order paid. |
| Row already `paid` | Zero rows updated. Log at most; this is the retry path working. |
| `payment_status !== "paid"` | Return. Subscribe to `checkout.session.async_payment_succeeded` if you accept delayed-notification methods. |
| Genuinely transient failure (D1 unavailable) | **Throw.** That is what earns a retry. |

The distinction in the last two rows is the one to get right: throw only when a
retry could plausibly succeed. Throwing on an unknown order id buys three
pointless retries and then a `failed` row, when the honest outcome was "this
event is not mine".

> An inbound event is recorded and deduplicated **before** it is queued, so if
> the queue send itself fails, Stripe's redelivery is suppressed for that event
> id. Replay it with `POST /webhooks/v1/inbound/events/:id/retry`. This applies
> to every provider, not just Stripe.


## What this recipe does not cover

- **Refunds.** `services.stripe.refunds.create(...)` from an action, plus a
  `stripe:charge.refunded` handler, following the same shape.
- **Recurring billing.** Use the
  [subscriptions plugin](/platform/auth/plugins/subscriptions) — it already owns
  tiers, the portal, renewals and cancellation.
- **Marketplaces.** Read `ctx.event.account` to route a connected account's
  event; see [Stripe Connect](/platform/webhooks/inbound#stripe-connect).

## Next

- [The Stripe dev loop](/platform/payments/dev-loop) — run this locally before you ship it.
