vibes.diy/docs Home Discord
creator docs · reference · generated

Stripe payments — take real money into the owner's own Stripe account

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/stripe.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks to take money — pay the bill at a table, split the check, take tips or donations, charge for a booking, collect dues, sell tickets, take a deposit, sell a download — build it on Stripe Checkout from backend.js, through ctx.stripe — the owner connects their Stripe account once with Connect Stripe, and the platform holds the credential. This is a backend.js feature (see the backend docs for the general handler contract); this page is the recipe that keeps every Stripe call on the server, keeps "paid" a fact only Stripe can establish, and keeps every order honest about where it is.

This skill comes in only when the person asks for payment. A storefront that records orders, a plans page that records requests, a booking page that holds a slot — each of those takes no money and says so in its own words, and they stay that way until the person asks to charge for them. What you may do unasked is offer it: on an app that sells, books, collects dues or sells tickets, the closing row's capability option is ▸ [pay] Take card payments with Stripe, gold when nothing else fits better, and a tap on it is the ask. Words that are the ask: "charge", "pay", "paid", "payment", "pay the bill", "split the check", "tip", "donate", "deposit", "dues", "tickets", "sell", "checkout", "Stripe". Words that are not: "orders", "price", "plans", "cart" — those describe records, not money moving.

One-time charges only. This recipe takes a payment and records that it happened. It does not mirror a Stripe subscription — cancellation, a failed renewal, an expiry — so a "subscription" or "paid tier" built on a permanently paid order would keep unlocking after billing stopped. Build recurring money as a one-time charge per period the app can see (annual dues, a season pass, a monthly bundle with its own order each month), say so plainly, and leave mode=subscription out of this recipe. A real subscription — a paid newsletter, a monthly club — is the stripe-subscriptions skill, which mirrors Stripe's subscription state into the app and takes the person's cancellations and failed renewals into account.

The money is the owner's, and so is the account. Every payer pays the same Stripe account, the one the owner connected; the app is a merchant's page. An app where each visitor collects money into their own Stripe account is a marketplace, a bigger thing than this recipe — say so plainly and build the one-merchant half.

Who pays: a guest, unless the record is theirs to keep.

A payment to a venue is guest checkout: the payer needs no Vibes DIY account, the payment record is keyed to the thing being paid and its Checkout Session, and backend.js writes it on the guest's behalf.

A restaurant check, a split-the-check page, a tip jar, a donation page, a pay-at-the-counter page, event tickets bought on the spot — the payer is a guest at the owner's place, and Stripe's own page asks for their email and sends the receipt. Security rests on Stripe, not on a sign-in: the backend reads every session back from Stripe before anything counts as paid. Guest checkout at a venue at the end of this page is that recipe, with a table's check as the worked example.

Sign-in is the shape when the record belongs to a person over time: a member's dues on the roster, a booking under their name, a "my orders" or "my tickets" page they come back to, and every subscription (the stripe-subscriptions skill). Then the order carries the buyer's handle, and Complete backend.js below is that recipe, with club dues as the worked example. Sections 1 to 6 hold for both.

1. The setup item: Connect Stripe

The owner connects Stripe, once. The Setup surface it lands on — the owner-only entry in the app's navigation, the verdict from /setup, the re-read on focus that unlocks the Pay buttons in place — is the use-vibe docs' Some apps need setup before they work; this recipe contributes one item to it. The Stripe item is Connect Stripe: the backend reads ctx.stripe.status(), the item is done when the mode the app charges in reads ready, and its steps link this vibe's Settings tab, where the owner presses Connect Stripe under Payments. The steps are written for someone who has never opened Stripe, so each one is a link to the exact page and uses the words that page uses:

  1. Open this vibe's Settings tab on vibes.diy: https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings (that is My Vibes → this vibe → Settings), and press Connect Stripe under Payments. Stripe opens its own setup pages: sign in with a Stripe login or create one there, and answer Stripe's questions about the business. The Stripe account belongs to the owner's Vibes DIY account and serves every app they make, so it is connected once; an app that takes money for a friend lives on the friend's own account, where they connect their Stripe.
  2. Come back to the app. The Pay buttons work in test mode the moment Stripe says the account is ready, with no redeploy; the card 4242 4242 4242 4242 with any future date pays.
  3. Go live, one app at a time. Once a test payment goes through, press Go live with Stripe under Payments on the Settings tab and finish Stripe's live setup (once, for every app). Then press Take live payments in this app on this app's Setup screen. This step stays on the Setup screen after the item is done, with a badge that reads Test mode until this app takes live payments.

Those steps are the item's copy on the Setup surface, with the vibe's real handle and slug filled in from the ownerHandle and appSlug the /setup route hands over: https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings for where the button is, and Connect Stripe and Payments in so many words. "The Settings page" is a direction to search for, and a person who has never seen it has to go looking. In the consumer flow, a Pay button whose item is not done reads Checkout is not open yet; the owner's Setup entry is where the steps are.

The owner pastes nothing: the platform holds the credential and attaches it to every call, and the backend names only which of the owner's two accounts — test or live — a call is for. An app made before Connect Stripe, whose backend.js already reads ctx.secrets.STRIPE_SECRET_KEY, keeps that helper as it is: an edit to it changes what was asked and leaves its payments where they are, and it moves to ctx.stripe only when the owner asks to switch to Connect Stripe. Every new payment app is built on ctx.stripe.

Test mode first, live one app at a time

ctx.stripe.status() answers { test: { available, state }, live: { available, state } }. available says whether vibes.diy takes payments in that mode on this deployment; state is the owner's account in that mode: absent (not connected yet), onboarding (Stripe is still collecting the owner's details — the Settings button reads Finish Stripe setup), ready (card payments on) or disconnected (the button reads Connect Stripe again).

The Stripe account is the owner's and serves every app they make, so the live account turning ready says the owner can take real money, not that this app is done rehearsing. Going live is the owner's choice per app:

An app charges in live only after its owner chooses live for this app, and from then on live is its only account: a live account that is not ready closes checkout until the owner reconnects.

The Pay request carries the mode the buyer was shown, and the backend opens Checkout only when it matches the mode the app charges in now.

  • The choice is a setup:live document the backend writes when the owner presses Take live payments in this app on the Setup screen, offered once live.state reads ready. Without it the app charges in test; with it, in live. The mode comes from that document alone, so a slow or failed status read never moves an app between accounts.
  • A live app stays live. A live account that reads onboarding or disconnected closes checkout, and the Setup item names the button that brings it back; the test account keeps its rehearsal role.
  • /setup hands the mode to everyone, and the Pay request carries the mode the buyer was shown. The backend compares it with the mode it charges in now, and a page that still shows the other mode is asked to refresh before any Checkout opens.
  • While the mode is test, the Pay button sits beside a Test mode badge. The owner sees the test card 4242 4242 4242 4242 to rehearse with; buyers read that test purchases end when the app goes live.
  • Every order keeps the mode it was made in and settles in that mode, and the app counts only orders made in its current mode: after going live, a paid test order reads as a rehearsal and unlocks nothing.
  • The Go-live step reads live.available, which /setup hands the owner as liveOpen. While it is true, the step names the Go live with Stripe button; until then, the step says test mode runs the whole flow end to end and that the Go live button appears on the same Settings tab when vibes.diy opens live payments. A live app whose liveOpen turns false tells its owner that vibes.diy has paused live payments, in its own words.

2. Stripe stays on the server, and so does Stripe.js

Every Stripe request is made in backend.js with ctx.stripe.fetch(path, { method, body, headers, mode }); App.jsx never calls api.stripe.com and never loads Stripe.js.

The client talks to the app's own /_api/... routes, and the backend holds no Stripe credential at all: ctx.stripe.fetch sends the call to the platform, which attaches the key and the owner's account and hands back Stripe's own Response. path is the Stripe API path from /v1 (/v1/checkout/sessions), body is the form as a URLSearchParams, headers carries the Idempotency-Key, and mode is "test" or "live". The calls this recipe makes — create a Checkout Session and read it back — are calls ctx.stripe carries, along with expiring an open session.

Stripe.js is not needed either: Stripe Checkout is a page Stripe hosts. The backend creates a Checkout Session and gets back a URL; the browser opens that URL; Stripe renders the card form, handles 3-D Secure, Apple Pay, Google Pay and every regional method the owner has turned on; and the buyer lands back on the app. Nothing about the card ever touches the app's code or its documents, which is also why the app has no PCI question to answer.

Checkout opens in a new tab with window.open, because the app runs inside a frame that cannot navigate the page around it.

window.location = url would navigate only the app's own frame, leaving Stripe's page boxed inside the app's chrome, and the frame is not allowed to redirect the page around it. window.open(url, "_blank") is allowed, so the pay button calls the backend, then opens the returned URL in a new tab from inside the click handler (a tab opened later, after an await, is a popup the browser blocks). Say in the button's own copy that Stripe opens in a new tab. window.open can still answer null when a browser blocks it; check that before asking the backend for anything, so a blocked tab costs no order. And a Pay button is disabled from the click until the tab is on its way, so a double tap does not start two purchases.

3. One order, then one Checkout Session

The order exists before the money moves. In the signed-in recipe the /checkout route runs as the signed-in buyer (the page calls it with apiFetch, so ctx.userInfo is set), writes an order document in pending with the buyer's handle, the title, the amount and the mode it is made in, and only then asks Stripe for a session for exactly that order. A guest's payment goes the same way once it has reserved its share of the check (Guest checkout at a venue).

Every order records the mode it was made in, and every ctx.stripe.fetch call for that order passes the order's own mode.

  • The order's mode is the app's current mode — test until the owner takes this app live, live after — and the session, the read-back and the sweep all use the order's own mode. A test order is a test-mode session on the owner's test account and stays one, so after the app goes live a pending test order still settles (or expires) where it was made. (This mode is the option ctx.stripe.fetch takes; the form's own mode=payment is Stripe's Checkout field, and both are always there.)
  • The whole payment lands in the owner's own Stripe account: the session carries the line items, the order's ids and the return URLs, and Stripe pays the merchant directly.
  • mode=payment, always: one charge, one order (see One-time charges only).
  • The price is inline price_data — currency, unit_amount, a product name — so the owner never has to create products in the dashboard first.
  • client_reference_id and metadata[orderId] both carry the order's _id, so the session names the order from Stripe's side too.
  • success_url and cancel_url are the app's public address with the order in the fragment: https://vibes.diy/vibe/<owner>/<slug>#paid=<orderId> and #cancelled=<orderId>. The fragment never reaches the server, so the return page is the fast cached one.
  • Idempotency-Key is the order's _id, so a retried request mints one session, not two. And a second press for the same thing reuses the open attempt: the route looks for this buyer's pending order of the same kind with a session still inside its day, and hands back that session's URL rather than minting another order — one purchase attempt, however many times the button or the network retried it.

The routes read the orders database, which access.js binds, so backend.js declares config.fetch.unfilteredReads for it — the obligation that comes with it is yours: in the signed-in recipe every route checks order.buyerHandle against the caller before it returns or settles anything, on a guest page every route answers only about the check and the payment the request names, and no route lists orders for anyone. The owner's view of every order is the page's own live query, which the access function's orders channel already scopes to the owner role.

Amounts are integers in the currency's smallest unit: $12.50 is unit_amount 1250, never 12.5.

Keep the amount an integer in the document as well (amount: 1250), compute it once on the server from the app's own price list, and never trust an amount the browser sends — the route takes what is being bought and looks the price up. The API is form-encoded, not JSON: build the body with URLSearchParams, and write nested fields in bracket form exactly as shown. Stripe answers JSON; the fields this recipe reads are id and url when it creates a session, and payment_status, status, client_reference_id, amount_total and currency when it reads one back.

4. "Paid" is a fact the backend reads back from Stripe

An order is marked paid only after the backend has read the Checkout Session back from Stripe, seen payment_status equal to paid, and matched the session's client_reference_id, amount_total and currency to the order; a browser write can never set it.

The match is what binds a payment to this order: a session is proof that somebody paid something, and the order's own _id, amount and currency in the session are what say it was this. A sessionId is a field in a document, and a document field is only as trustworthy as the rules that guard it — so the read-back checks Stripe's copy, never the order's.

Two things settle an order, and both are the backend reading GET /v1/checkout/sessions/<id> through ctx.stripe, in the order's own mode:

  • The return. When the app opens with #paid=<orderId> in its address, the page calls /confirm for that order; the route reads the session back, and if payment_status is "paid" it moves the order to paid and stamps paidAt. A session whose status is "expired" moves the order to expired. Anything else leaves it pending.

A scheduled sweep settles every order still pending by reading its session back from Stripe, so a buyer who closes the tab is still marked paid.

Settling is idempotent: the backend reads the order first, and an order that is no longer pending comes back as it is, with ok.

The return can arrive more than once for one payment, sometimes at the same moment: the page runs its check on mount and again on hashchange, a reload repeats it, and the sweep may get there first. So /confirm answers from the order it reads, and already paid is a success. When two calls settle at once, the second write meets conflict (or access-denied, when the access rule lets only a pending order change); the route reads the order again, and an order now settled from this same session is the answer it gives.

  • The sweep. A buyer who pays and closes the tab never triggers /confirm. Every few minutes scheduled queries orders by status = "pending", reads each one's session back, and settles it the same way. A Checkout Session expires on its own after 24 hours, so the sweep's work is bounded by orders made in the last day, and an order older than that with no session is marked expired rather than read forever.

Why not a Stripe webhook: a webhook arrives with no identity, and a write with no identity has to be opened to every anonymous visitor to be accepted — the wrong door for the one record that means money changed hands. Reading the session back through ctx.stripe is the same proof, from the same source, on a route that can only move an order to the state Stripe reports. access.js makes that structural: an existing order changes only on a write from the app's own backend.js (ctx.requireBackend() on every update, not only on the status), so a buyer cannot swap a cheaper session's id onto a dearer order, and the two handlers that write orders do so only after reading Stripe. A buyer never edits an order; they make a new one.

5. What the buyer and the owner each see

  • A signed-in buyer sees their own orders and each one's state — pending with a "Finish paying" link that reuses the session URL while it is open, paid with the date, expired with a fresh "Pay" button that makes a new order. A guest sees the thing they paid toward: the table's check moving to Paid in full, the tip jar's total rising, and a thank-you the moment they come back from Stripe.
  • The owner sees every order: who, what, how much, when, and a total of what has been paid. This is the app's own ledger; it is also in the Stripe dashboard, and the two agree because the app only ever copies Stripe's answer.
  • A paid order unlocks whatever it was for in the app's own terms — the member shows as dues paid on the roster, the booking shows as deposit received, the ticket shows as going — read off the order's status, never off the return URL.

6. Errors keep their reason

Stripe's own answers arrive as a Response: a card decline or a bad parameter is JSON with error.message; keep that sentence and return it as { ok: false, reason: "stripe", message } so the page can show why. A platform answer is thrown as an Error with e.reason, and each reason names the owner's next step: not_connected and not_ready are the Setup item (connect, or let Stripe finish its checks), account_disconnected is Connect Stripe again from the same Settings tab, and not_available means payments in that mode are paused on vibes.diy for now. Log it with ctx.log and hand the reason to the page, which reads it as Checkout is not open yet.

Complete backend.js

The signed-in recipe, with a club's dues as the example: the order carries the buyer's handle because the roster reads it for the rest of the year.

backend.js

js// Stripe Checkout for one merchant: the owner's connected Stripe account, hosted Checkout,
// orders settled by reading each session back. Nothing in this file touches a card or a key.
export const config = {
  scheduled: { interval: "5m" },
  // The routes read a buyer's own order back; access.js binds the database, so the fetch lane
  // needs this declaration, and the routes owe the buyerHandle check it waives.
  fetch: { unfilteredReads: { dbs: ["orders"], why: "the /checkout and /confirm routes read the signed-in buyer's own order, checked by buyerHandle before anything is returned" } },
};

const DB = "orders";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // a Checkout Session expires on its own after a day

// The app's own price list, in the currency's smallest unit. The browser sends a
// `kind`, never an amount.
const PRICES = {
  dues: { title: "Annual membership dues", amount: 4000, currency: "usd" },
  deposit: { title: "Session deposit", amount: 5000, currency: "usd" },
};

function json(body, status = 200) {
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}

function appUrl(ctx, hash) {
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
}

// The owner's choice to take THIS app live. The Stripe account is the owner's and serves all of
// their apps, so its live account turning ready never moves an app by itself.
const LIVE_CHOICE = "setup:live";

// Which of the owner's two Stripe accounts this app charges in, decided by the owner's choice
// alone; then whether that account takes card payments right now. A live app whose live account
// is not ready stays live with checkout closed until the owner reconnects.
async function checkoutMode(ctx) {
  const mode = (await ctx.db.get(LIVE_CHOICE, { db: DB })) ? "live" : "test";
  let st;
  try {
    st = await ctx.stripe.status();
  } catch (e) {
    ctx.log("warn", "stripe status unavailable", { message: e.message });
    return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
  }
  return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
}

// One call to Stripe on the owner's account, in the order's own mode. Form-encoded in, Stripe's
// JSON out, the refusal's own sentence kept.
async function stripe(ctx, mode, method, path, form, headers = {}) {
  let res;
  try {
    res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
  } catch (e) {
    // The platform's answer, not Stripe's: a Setup step for the owner, or payments paused.
    ctx.log("warn", "stripe call refused", { reason: e.reason });
    return { ok: false, reason: e.reason ?? "not_available" };
  }
  const body = await res.json().catch(() => null);
  if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
  return { ok: true, body };
}

// Move one pending order to what Stripe says about its session. Returns the order as settled.
async function settle(ctx, order) {
  if (order.status !== "pending") return order;
  if (!order.sessionId) {
    if (Date.now() - order.createdAt > SESSION_TTL_MS) {
      const expired = { ...order, status: "expired" };
      await ctx.db.put(expired, { db: DB, id: order._id, ifRev: order._rev });
      return expired;
    }
    return order;
  }
  const session = await stripe(ctx, order.mode, "GET", `/checkout/sessions/${order.sessionId}`);
  if (!session.ok) return order; // a refusal settles nothing; the next read tries again
  const s = session.body;
  // The session must be THIS order's: Stripe's copy of the order id, amount and currency,
  // never the document's own claim about which session it holds.
  const matches = s.client_reference_id === order._id && s.amount_total === order.amount && s.currency === order.currency;
  if (!matches) {
    ctx.log("warn", "stripe session does not match its order", { orderId: order._id });
    return order;
  }
  let next = order;
  if (s.payment_status === "paid") {
    next = { ...order, status: "paid", paidAt: Date.now(), amountPaid: s.amount_total };
  } else if (s.status === "expired") {
    next = { ...order, status: "expired" };
  }
  if (next === order) return order;
  try {
    await ctx.db.put(next, { db: DB, id: order._id, ifRev: order._rev });
  } catch (err) {
    if (err.code !== "conflict" && err.code !== "access-denied") throw err;
    // Another call settled it first: that is this payment's answer when it holds the same session.
    const stored = await ctx.db.get(order._id, { db: DB });
    if (stored && stored.status !== "pending" && stored.sessionId === order.sessionId) return stored;
    throw err;
  }
  return next;
}

export async function fetch(request, ctx) {
  const url = new URL(request.url);

  // The setup verdict: ready for everyone, the items (and the handle + slug the Setup screen
  // links with) for the owner alone.
  if (url.pathname === "/setup" && request.method === "GET") {
    const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
    const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
    const item = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
    if (mode === "live" && !liveOpen) item.problem = "Vibes DIY has paused live payments for now; checkout reopens here by itself when they return.";
    const items = [item];
    const ready = items.every((i) => i.done);
    // `mode` is everyone's: the Pay button wears a Test mode badge while the app takes test payments.
    if (!owner) return json({ ok: true, ready, owner, mode });
    // The Go-live step: `liveOpen` (vibes.diy takes live payments) and `liveReady` (the owner's live
    // Stripe account is ready, so this app can be taken live).
    return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
  }

  // The owner takes this app live: one document, written for the owner alone, once their live
  // Stripe account is ready. From here on the app charges in live only.
  if (url.pathname === "/go-live" && request.method === "POST") {
    if (ctx.userInfo?.userHandle !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
    const { liveReady } = await checkoutMode(ctx);
    if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
    const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
    if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
    return json({ ok: true, mode: "live" });
  }

  // Start a purchase: the order first, then a Checkout Session for exactly that order.
  if (url.pathname === "/checkout" && request.method === "POST") {
    const me = ctx.userInfo?.userHandle;
    if (!me) return json({ ok: false, reason: "sign-in" }, 401);
    const { mode, open, state } = await checkoutMode(ctx);
    const { kind, mode: shown } = await request.json();
    // The buyer pays in the mode their page showed them, or not at all: a page that still shows
    // the other mode refreshes first.
    if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
    if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
    const price = PRICES[kind];
    if (!price) return json({ ok: false, reason: "unknown-item" }, 400);

    // One attempt per buyer per item: a second press, a retry after a lost answer, a reload,
    // all land on the session already open rather than minting another order.
    const pending = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100 });
    const attempt = pending.find((o) => o.buyerHandle === me && o.kind === kind && o.mode === mode && o.sessionUrl && Date.now() - o.createdAt < SESSION_TTL_MS);
    if (attempt) return json({ ok: true, orderId: attempt._id, url: attempt.sessionUrl });

    const id = "order-" + crypto.randomUUID();
    const order = {
      _id: id,
      type: "order",
      kind,
      title: price.title,
      amount: price.amount,
      currency: price.currency,
      buyerHandle: me,
      mode, // the account this order is made in; every later call for it passes the same mode
      status: "pending",
      createdAt: Date.now(),
    };
    await ctx.db.put(order, { db: DB, id });

    const form = new URLSearchParams({
      mode: "payment",
      "line_items[0][quantity]": "1",
      "line_items[0][price_data][currency]": price.currency,
      "line_items[0][price_data][unit_amount]": String(price.amount),
      "line_items[0][price_data][product_data][name]": price.title,
      client_reference_id: id,
      "metadata[orderId]": id,
      success_url: appUrl(ctx, `#paid=${id}`),
      cancel_url: appUrl(ctx, `#cancelled=${id}`),
    });
    const session = await stripe(ctx, mode, "POST", "/checkout/sessions", form, { "idempotency-key": id });
    if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);

    await ctx.db.put({ ...order, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id });
    return json({ ok: true, orderId: id, url: session.body.url });
  }

  // The buyer is back: read the session, move the order to what Stripe says.
  if (url.pathname === "/confirm" && request.method === "POST") {
    const me = ctx.userInfo?.userHandle;
    if (!me) return json({ ok: false, reason: "sign-in" }, 401);
    const { orderId } = await request.json();
    const order = await ctx.db.get(orderId, { db: DB });
    if (!order || order.buyerHandle !== me) return json({ ok: false, reason: "not-found" }, 404);
    const settled = await settle(ctx, order);
    return json({ ok: true, status: settled.status });
  }

  return json({ ok: false, reason: "not-found" }, 404);
}

// The sweep: anyone who paid and closed the tab is still marked paid within a few minutes.
export async function scheduled(event, ctx) {
  let after;
  let settled = 0;
  do {
    const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
    for (const order of page) {
      const next = await settle(ctx, order);
      if (next !== order) settled += 1;
    }
    after = page.next;
  } while (after);
  ctx.log("info", "stripe sweep", { settled, at: event.scheduledTime });
}

access.js — the status moves only on the backend's word

access.js

jsexport function orders(doc, oldDoc, user, ctx) {
  if (!user?.userHandle) throw { forbidden: "sign in" };
  // The app's go-live choice: written by backend.js on the owner's /go-live call alone, read on
  // the owner's channel.
  if (doc.type === "setup") {
    ctx.requireBackend("fetch");
    return { channels: ["orders"] };
  }
  if (doc.type !== "order") throw { forbidden: "unknown document type" };
  if (oldDoc) {
    // Only backend.js changes an existing order — the /checkout route adding the session, the
    // /confirm route and the sweep settling it — so no field (sessionId included) is a buyer's to edit.
    ctx.requireBackend();
    if (doc.buyerHandle !== oldDoc.buyerHandle || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.kind !== oldDoc.kind || doc.mode !== oldDoc.mode) {
      throw { forbidden: "an order keeps its buyer, its price and its mode" };
    }
  } else {
    if (doc.buyerHandle !== user.userHandle) throw { forbidden: "an order belongs to its buyer" };
    if (doc.status !== "pending") throw { forbidden: "an order starts pending" };
    ctx.requireBackend("fetch"); // the /checkout route writes it, so the amount is the server's
  }
  const mine = `user:${doc.buyerHandle}`;
  return { channels: [mine, "orders"], grant: { users: { [doc.buyerHandle]: [mine] }, roles: { owner: ["orders"] } } };
}

App.jsx sketch

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch, useVibe } from "use-vibes";
import { Badge, Button, Heading, Row, Stack, Text } from "@vibes.diy/pouf";

// The setup verdict, read on mount and again whenever the app comes back into view: the owner
// connects Stripe in another tab and returns, and the Pay buttons unlock without a reload.
function useSetup() {
  const [setup, setSetup] = useState(null); // { ready, owner, mode, liveOpen?, items?, ownerHandle?, appSlug? }
  const latest = useRef(0);
  useEffect(() => {
    // Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
    // answer overwrites a fresh one.
    const load = () => {
      const n = ++latest.current;
      apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
    };
    load();
    const onVisible = () => { if (document.visibilityState === "visible") load(); };
    document.addEventListener("visibilitychange", onVisible);
    window.addEventListener("focus", load);
    window.addEventListener("setup-changed", load); // the Setup screen's own Take live button
    return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
  }, []);
  return setup;
}

// The button the Settings tab shows for each state of the owner's Stripe account, in its own words.
const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };

// The Setup surface, the owner's alone: one item, every step a link to the exact page in that
// page's own words, with this vibe's real handle and slug filled in.
function SetupScreen({ setup }) {
  const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
  const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
  const item = setup.items.find((i) => i.id === "stripe-connect");
  const live = setup.mode === "live";
  const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
  return (
    <Stack>
      <Heading>Setup</Heading>
      <Row gap={2}>
        <Text>{item.label}</Text>
        <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
      </Row>
      {item.problem && <Text muted>{item.problem}</Text>}
      <ol>
        <li>
          Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
          and press <b>{STRIPE_BUTTON[item.state] ?? "Connect Stripe"}</b> under <b>Payments</b>.
          Stripe opens its own setup pages: use your Stripe account, or create one there. Your Stripe account serves every app you make.
        </li>
        <li>
          Come back here: the Pay buttons work as soon as Stripe says your account is ready. In test mode the card 4242 4242 4242 4242 with any future date pays.
        </li>
        <li>
          {/* The Go-live step stays here once the item is done; its badge says which account this app charges in. */}
          <Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
          {live ? (
            <>This app takes real payments into your Stripe account; refunds and payouts are in your Stripe Dashboard.</>
          ) : setup.liveReady ? (
            <>
              Your live Stripe account is ready. Each app goes live on its own, so this one rehearses until you press{" "}
              <Button onClick={takeLive}>Take live payments in this app</Button>. Buyers start fresh in live; test purchases stay rehearsals.
            </>
          ) : setup.liveOpen ? (
            <>When a test payment goes through, press <b>Go live with Stripe</b> under <b>Payments</b> on <a href={settingsUrl} target="_blank" rel="noreferrer">the same Settings tab</a> and finish Stripe's live setup, once for all your apps; then come back here to take this app live.</>
          ) : (
            <>Test mode runs the whole flow end to end. The <b>Go live with Stripe</b> button appears under <b>Payments</b> on the same Settings tab when vibes.diy opens live payments.</>
          )}
        </li>
      </ol>
    </Stack>
  );
}

export default function App() {
  const { me } = useVibe("orders");
  const { useLiveQuery } = useFireproof("orders");
  const { docs: orders } = useLiveQuery("type", { key: "order" });
  const setup = useSetup();
  const [screen, setScreen] = useState("membership");
  const [error, setError] = useState(null);

  // Back from Stripe: ask the backend to read the session and settle the order. The wrapper hands
  // the app its fragment just after first render and fires hashchange, so read the hash now AND on
  // every change — a mount-only read can miss it.
  useEffect(() => {
    const confirm = () => {
      const m = /#paid=([\w-]+)/.exec(location.hash);
      if (!m) return;
      history.replaceState(null, "", location.pathname);
      apiFetch("/confirm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orderId: m[1] }) });
    };
    confirm();
    window.addEventListener("hashchange", confirm);
    return () => window.removeEventListener("hashchange", confirm);
  }, []);

  const [busy, setBusy] = useState(false);
  const pay = async (kind) => {
    setError(null);
    const tab = window.open("", "_blank"); // opened inside the click, before any await, so it is not a popup
    if (!tab) {
      setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and press Pay again.");
      return; // nothing was ordered
    }
    setBusy(true);
    try {
      // The mode this page shows rides the request, so the buyer pays in the mode they saw.
      const res = await apiFetch("/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind, mode: setup.mode }) });
      const data = await res.json();
      if (!data.ok) {
        tab.close();
        setError(
          data.reason === "stripe" ? data.message
          : data.reason === "mode-changed" ? `Payments here just moved to ${data.mode} mode. Refresh the page and press Pay again.`
          : "Checkout is not open yet."
        );
        return;
      }
      tab.location.href = data.url;
    } finally {
      setBusy(false);
    }
  };

  const ready = setup?.ready === true;
  const mine = orders.filter((o) => o.buyerHandle === me?.userHandle);
  // Only orders made in the app's current mode count: after going live, test orders are rehearsals.
  const paid = mine.some((o) => o.kind === "dues" && o.status === "paid" && o.mode === setup?.mode);
  return (
    <Stack>
      <Row gap={2}>
        <Button variant="quiet" onClick={() => setScreen("membership")}>Membership</Button>
        {/* The Setup entry: the owner's alone, highlighted while an item is still to do, quiet once done. */}
        {setup?.owner && (
          <Button variant={ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
            Setup{ready ? "" : " · to do"}
          </Button>
        )}
      </Row>
      {screen === "setup" && setup?.owner ? (
        <SetupScreen setup={setup} />
      ) : (
        <Stack>
          <Heading>Membership</Heading>
          {paid ? (
            <Badge tone="up">Dues paid</Badge>
          ) : ready ? (
            <Stack>
              {/* Test mode is a rehearsal: the owner gets the test card, buyers learn it ends at go-live. */}
              {setup.mode === "test" && (
                <Row gap={2}>
                  <Badge tone="info">Test mode</Badge>
                  <Text muted>{setup.owner ? "Pay with the test card 4242 4242 4242 4242 and any future date." : "This is a rehearsal: test purchases end when the app goes live."}</Text>
                </Row>
              )}
              <Button onClick={() => pay("dues")} disabled={busy} loading={busy}>
                Pay $40 dues — Stripe opens in a new tab
              </Button>
            </Stack>
          ) : (
            <Text muted>Checkout is not open yet.</Text>
          )}
          {error && <Text>{error}</Text>}
          {setup?.owner && (
            <ul>
              {orders.map((o) => (
                <li key={o._id}>@{o.buyerHandle} · {o.title} · {(o.amount / 100).toFixed(2)} {o.currency.toUpperCase()} · {o.status}{o.mode === setup.mode ? "" : ` · ${o.mode} rehearsal`}</li>
              ))}
            </ul>
          )}
        </Stack>
      )}
    </Stack>
  );
}

The sketch is one item; a page with several keeps the same shape with one entry per item in PRICES and one pay(kind) per button. Something that recurs is a new order each period — next year's dues are next year's pay("dues") — and a true subscription, where Stripe renews and retries on its own, is the stripe-subscriptions skill.

Guest checkout at a venue: a table's check

A restaurant check is the fullest venue page; a tip jar, a donation page or a pay-at-the-counter page is the same recipe with one line and no splitting. Staff open a check with its lines, its link goes on the table as a QR code, and anyone at the table opens it and pays, with no account.

A check is paid by one payer by default: one primary button pays the whole remaining balance plus the tip, and splitting is a second choice the table opens.

Below the primary Pay $X, a quiet Split the check opens the other two ways: the lines I had, or an even share of what is left. The page sends what is paid (pay: "rest", "lines" with ids, or "share" with how many ways) and a tip percentage from its own list; the backend prices it from the check.

Every record is written by backend.js, guest or not: access.js takes a payment's creation only from ctx.requireBackend("fetch") and its settling only from the backend, keeps each type at its own ids, and takes the go-live record only from the owner. A page writes nothing but the owner's own checks.

A guest's write through the route is still a signed-out write: user is null, ctx.requireBackend("fetch") passes, the record returns allowAnonymous: true, and the app's anonymous-write setting has to admit it. So the owner's Setup screen carries a let guests pay step: publishing from the editor turns the setting on, and after a CLI push the owner runs vibes-diy app anon-write <handle>/<slug> on, with the real handle and slug filled in. With it off, /checkout answers guests-closed and the page reads Checkout is not open yet.

Before a Checkout Session is created, the payment reserves its share of the check with one revision-checked write.

  • One ledger per check per mode (ledger:<mode>:<checkId>) holds what is paid, which lines are paid, and what each open payment holds. /checkout reads it, prices the request against what is free, adds a hold and writes it back with ifRev; a second payer who moved in the same instant meets conflict, reads again, and finds the balance held — the answer is someone at the table is paying right now. So no balance and no line is ever in two open sessions.
  • The payer pays what they were shown. The page sends the subtotal it displayed as quote; when the check moved in between, the route reserves nothing and answers quote-changed with the new amount for the page to show.
  • An even split is fixed once chosen. The first share writes the split onto the ledger: N shares of what was free, the rounding cents on the last share. Every later share pays the next open one, so two ways on $100 is $50 and $50 however the first one ends.
  • Settling moves the hold. /confirm reads the session back from Stripe; paid adds the hold to the ledger's paid total and lines, expired gives it back, and the ledger's settled map makes a repeat a no-op. A failed session create, a payer back from Stripe without paying (/release expires their session) and a second press of Pay (which replaces the payer's own attempt) all give the hold back the same way. A read-back Stripe cannot answer right now changes nothing: the hold stays, and a payer replacing their own attempt hears try again in a moment.
  • The sweep is the safety net: every minute it settles payments whose payer closed the tab, and anything still pending a day after its createdAt is expired whatever its session says, since sessions close after 30 minutes (expires_at).
  • The page reads the ledger for its mode with a live query: paid and left move on every phone at once, and Paid in full shows at zero.

Stripe's page asks the payer for their email and sends the receipt (the owner turns receipts on under customer emails in their Stripe Dashboard); the app keeps no email and no name. submit_type: "pay" makes Stripe's button read Pay; a donation page passes "donate".

backend.js

js// A table's check, paid by guests through Stripe Checkout into the owner's connected account.
// Anyone with the table's link pays; every record is written here, never by a page.
export const config = {
  scheduled: { interval: "1m" },
  // Guests have no account, so the routes read unfiltered; each answers only about the check or
  // the payment the request names.
  fetch: { unfilteredReads: { dbs: ["checks"], why: "guests pay a table's check without an account; /checkout, /confirm and /release read only the check, its ledger and the payment the request names" } },
};

const DB = "checks";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const HOLD_SECONDS = 30 * 60; // a Checkout Session closes after 30 minutes
const TIPS = [0, 15, 18, 20, 25]; // percent of the subtotal
const LIVE_CHOICE = "setup:live";

function json(body, status = 200) {
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}

function appUrl(ctx, hash) {
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
}

async function checkoutMode(ctx) {
  const choice = await ctx.db.get(LIVE_CHOICE, { db: DB });
  const mode = choice?.type === "setup" && choice.mode === "live" ? "live" : "test";
  let st;
  try {
    st = await ctx.stripe.status();
  } catch (e) {
    ctx.log("warn", "stripe status unavailable", { message: e.message });
    return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
  }
  return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
}

async function stripe(ctx, mode, method, path, form, headers = {}) {
  let res;
  try {
    res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
  } catch (e) {
    ctx.log("warn", "stripe call refused", { reason: e.reason });
    return { ok: false, reason: e.reason ?? "not_available" };
  }
  const body = await res.json().catch(() => null);
  if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
  return { ok: true, body };
}

// One ledger per check per mode holds the money: what is paid, which lines are paid, and what
// each open payment has reserved. Every change is revision-checked, so two payers can never
// reserve the same balance or the same line.
const ledgerId = (checkId, mode) => `ledger:${mode}:${checkId}`;

async function changeLedger(ctx, checkId, mode, change) {
  const id = ledgerId(checkId, mode);
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const stored = await ctx.db.get(id, { db: DB });
    const ledger = stored ?? { _id: id, type: "ledger", checkId, mode, paid: 0, linesPaid: {}, holds: {}, settled: {} };
    const next = change(structuredClone(ledger));
    if (!next) return ledger; // nothing to change, or the change was refused
    try {
      await ctx.db.put(next, { db: DB, id, ifRev: stored ? stored._rev : null });
      return next;
    } catch (err) {
      if (err.code !== "conflict") throw err; // another payer moved first: read again and retry
    }
  }
  throw new Error("the check is busy");
}

// The sum a ledger has free to reserve, and the lines nobody has paid or reserved.
function freeOf(check, ledger) {
  const total = check.lines.reduce((n, l) => n + l.price, 0);
  const held = Object.values(ledger.holds);
  const taken = new Set([...Object.keys(ledger.linesPaid), ...held.flatMap((h) => h.lines)]);
  return { left: Math.max(0, total - ledger.paid), free: Math.max(0, total - ledger.paid - held.reduce((n, h) => n + h.subtotal, 0)), taken };
}

// An even split is fixed the first time someone picks it: N shares of what was free, the
// rounding cents on the last share, so a 2-way split of $100 stays $50 and $50.
function splitShares(free, ways) {
  const each = Math.floor(free / ways);
  return Array.from({ length: ways }, (_, i) => ({ amount: i === ways - 1 ? free - each * (ways - 1) : each, by: null }));
}

// Settle one pending payment by what Stripe says: paid moves its hold into the ledger's paid
// total, expired gives the hold back. /confirm, /release and the sweep all come here. A read-back
// Stripe could not answer keeps the hold until Stripe can, or until the day is out; `giveUp` is a
// payment whose session Stripe never opened.
async function settle(ctx, p, giveUp = false) {
  if (p.status !== "pending") return p;
  let outcome = null;
  if (p.sessionId) {
    const session = await stripe(ctx, p.mode, "GET", `/checkout/sessions/${p.sessionId}`);
    const s = session.ok ? session.body : null;
    const matches = s && s.client_reference_id === p._id && s.amount_total === p.amount && s.currency === p.currency;
    if (s && !matches) ctx.log("warn", "stripe session does not match its payment", { paymentId: p._id });
    if (matches && s.payment_status === "paid") outcome = "paid";
    else if (matches && s.status === "expired") outcome = "expired";
  }
  // Sessions close after 30 minutes, so a payment still pending a day later is over, whatever it holds.
  if (outcome === null && (giveUp || Date.now() - p.createdAt > SESSION_TTL_MS)) outcome = "expired";
  if (outcome === null) return p;
  await changeLedger(ctx, p.checkId, p.mode, (ledger) => {
    if (ledger.settled[p._id]) return null; // this payment already counted
    delete ledger.holds[p.holdId];
    ledger.settled[p._id] = outcome;
    const share = ledger.split?.shares[p.share];
    if (share) share.by = outcome === "paid" ? p._id : null;
    if (outcome === "paid") {
      ledger.paid += p.subtotal;
      for (const id of p.lines) ledger.linesPaid[id] = p._id;
    }
    return ledger;
  });
  const next = outcome === "paid" ? { ...p, status: "paid", paidAt: Date.now() } : { ...p, status: "expired" };
  try {
    await ctx.db.put(next, { db: DB, id: p._id, ifRev: p._rev });
  } catch (err) {
    if (err.code !== "conflict") throw err;
    const stored = await ctx.db.get(p._id, { db: DB }); // another call settled it first
    if (stored && stored.status !== "pending") return stored;
    throw err;
  }
  return next;
}

// A payer who came back without paying lets go of what they reserved, straight away.
async function release(ctx, paymentId) {
  const p = typeof paymentId === "string" ? await ctx.db.get(paymentId, { db: DB }) : null;
  if (p?.type !== "payment" || p.status !== "pending") return null;
  if (p.sessionId) await stripe(ctx, p.mode, "POST", `/checkout/sessions/${p.sessionId}/expire`);
  return settle(ctx, p);
}

export async function fetch(request, ctx) {
  const url = new URL(request.url);

  if (url.pathname === "/setup" && request.method === "GET") {
    const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
    const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
    const item = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
    if (mode === "live" && !liveOpen) item.problem = "Vibes DIY has paused live payments for now; checkout reopens here by itself when they return.";
    const items = [item];
    const ready = items.every((i) => i.done);
    if (!owner) return json({ ok: true, ready, owner, mode });
    return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
  }

  // The owner, and only the owner, takes this app live.
  if (url.pathname === "/go-live" && request.method === "POST") {
    if (ctx.userInfo?.userHandle !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
    const { liveReady } = await checkoutMode(ctx);
    if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
    const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
    if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
    return json({ ok: true, mode: "live" });
  }

  // Pay toward a check. Nobody signs in: the payment is keyed to the check and its session.
  if (url.pathname === "/checkout" && request.method === "POST") {
    const { mode, open, state } = await checkoutMode(ctx);
    const { checkId, pay, lineIds, ways, quote, tipPercent, replaces, mode: shown } = await request.json().catch(() => ({}));
    if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
    if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
    const check = typeof checkId === "string" ? await ctx.db.get(checkId, { db: DB }) : null;
    if (check?.type !== "check") return json({ ok: false, reason: "not-found" }, 404);
    // The payer's own earlier attempt lets go first; while Stripe cannot say how it ended, it keeps its hold.
    const earlier = await release(ctx, replaces);
    if (earlier?.status === "pending") return json({ ok: false, reason: "try-again" }, 503);

    // Reserve before Stripe hears of it: one revision-checked ledger write, priced from the check,
    // at exactly the amount the payer was shown.
    const id = "pay-" + crypto.randomUUID();
    const holdId = crypto.randomUUID();
    let hold = null;
    let refusal = { reason: "being-paid" };
    const refused = await changeLedger(ctx, check._id, mode, (ledger) => {
      const { left, free, taken } = freeOf(check, ledger);
      let lines = [];
      let share;
      let subtotal = free; // the default: everything still free on the check
      if (pay === "lines") {
        lines = [...new Set(Array.isArray(lineIds) ? lineIds : [])].map((lid) => check.lines.find((l) => l.id === lid));
        if (lines.length === 0 || lines.some((l) => !l || taken.has(l.id))) {
          refusal = { reason: "taken" };
          return null;
        }
        subtotal = lines.reduce((n, l) => n + l.price, 0);
      } else if (pay === "share") {
        if (!ledger.split) {
          const n = Math.min(20, Math.max(2, Math.round(Number(ways) || 2)));
          ledger.split = { ways: n, shares: splitShares(free, n) };
        }
        share = ledger.split.shares.findIndex((s) => s.by === null);
        subtotal = share < 0 ? 0 : ledger.split.shares[share].amount;
      }
      if (left === 0) refusal = { reason: "paid-in-full" };
      if (subtotal <= 0 || subtotal > free) return null;
      if (subtotal !== quote) {
        refusal = { reason: "quote-changed", quote: subtotal }; // the check moved since the page drew it
        return null;
      }
      hold = { subtotal, lines: lines.map((l) => l.id), share: share ?? null };
      ledger.holds[holdId] = hold;
      if (share !== undefined) ledger.split.shares[share].by = holdId;
      return ledger;
    }).then(
      () => null,
      (err) => err
    );
    if (refused) {
      // Most often the app's anonymous-write setting is off, so a guest's payment cannot be recorded.
      ctx.log("warn", "check reservation refused", { code: refused.code, message: refused.message });
      return json({ ok: false, reason: "guests-closed" }, 503);
    }
    if (!hold) return json({ ok: false, ...refusal }, 409);

    const tip = TIPS.includes(tipPercent) ? tipPercent : 0;
    const tipAmount = Math.round((hold.subtotal * tip) / 100);
    const payment = {
      _id: id,
      type: "payment",
      checkId: check._id,
      holdId,
      lines: hold.lines,
      share: hold.share,
      subtotal: hold.subtotal,
      tip: tipAmount,
      amount: hold.subtotal + tipAmount,
      currency: check.currency,
      mode,
      status: "pending",
      createdAt: Date.now(),
    };
    await ctx.db.put(payment, { db: DB, id, ifRev: null });

    const what = pay === "lines" ? check.lines.filter((l) => hold.lines.includes(l.id)).map((l) => l.name).join(", ").slice(0, 200) : pay === "share" ? "A share of the check" : "The check";
    const form = new URLSearchParams({
      mode: "payment",
      submit_type: "pay",
      "line_items[0][quantity]": "1",
      "line_items[0][price_data][currency]": check.currency,
      "line_items[0][price_data][unit_amount]": String(hold.subtotal),
      "line_items[0][price_data][product_data][name]": `${check.table} · ${what}`,
      client_reference_id: id,
      "metadata[checkId]": check._id,
      expires_at: String(Math.floor(Date.now() / 1000) + HOLD_SECONDS),
      success_url: appUrl(ctx, `#check=${check._id}&paid=${id}`),
      cancel_url: appUrl(ctx, `#check=${check._id}&cancelled=${id}`),
    });
    if (tipAmount > 0) {
      form.set("line_items[1][quantity]", "1");
      form.set("line_items[1][price_data][currency]", check.currency);
      form.set("line_items[1][price_data][unit_amount]", String(tipAmount));
      form.set("line_items[1][price_data][product_data][name]", `Tip (${tip}%)`);
    }
    const session = await stripe(ctx, mode, "POST", "/checkout/sessions", form, { "idempotency-key": id });
    if (!session.ok) {
      await settle(ctx, await ctx.db.get(id, { db: DB }), true); // no session: give the hold back now
      return json(session, session.reason === "stripe" ? 502 : 503);
    }
    const stored = await ctx.db.get(id, { db: DB });
    await ctx.db.put({ ...stored, sessionId: session.body.id }, { db: DB, id, ifRev: stored._rev });
    return json({ ok: true, paymentId: id, url: session.body.url });
  }

  // The payer is back: settle by what Stripe says, and answer from it.
  if (url.pathname === "/confirm" && request.method === "POST") {
    const { paymentId } = await request.json().catch(() => ({}));
    const p = typeof paymentId === "string" ? await ctx.db.get(paymentId, { db: DB }) : null;
    if (p?.type !== "payment") return json({ ok: false, reason: "not-found" }, 404);
    const settled = await settle(ctx, p);
    return json({ ok: true, state: settled.status });
  }

  if (url.pathname === "/release" && request.method === "POST") {
    const { paymentId } = await request.json().catch(() => ({}));
    await release(ctx, paymentId);
    return json({ ok: true });
  }

  return json({ ok: false, reason: "not-found" }, 404);
}

// The safety net: a payer who paid and closed the tab, or walked away from Stripe's page, is
// settled within a minute, and their hold comes back to the table.
export async function scheduled(event, ctx) {
  let after;
  let settled = 0;
  do {
    const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
    for (const p of page) {
      if (p.type !== "payment") continue;
      const next = await settle(ctx, p);
      if (next !== p) settled += 1;
    }
    after = page.next;
  } while (after);
  ctx.log("info", "check sweep", { settled, at: event.scheduledTime });
}

access.js

jsexport function checks(doc, oldDoc, user, ctx) {
  const table = (checkId) => {
    if (typeof checkId !== "string" || !/^check-[\w-]+$/.test(checkId)) throw { forbidden: "a record names its check" };
    return `check:${checkId}`;
  };
  // Each type owns its ids, so no record can stand in for another.
  const idFor = { setup: "setup:live", check: doc._id?.startsWith("check-") && doc._id, payment: doc._id?.startsWith("pay-") && doc._id, ledger: `ledger:${doc.mode}:${doc.checkId}` };
  if (!(doc.type in idFor) || doc._id !== idFor[doc.type]) throw { forbidden: "that id belongs to another kind of record" };
  if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };

  // The owner's go-live choice: backend.js writes it on the owner's own /go-live call.
  if (doc.type === "setup") {
    ctx.requireBackend("fetch");
    ctx.requireRole("owner");
    if (doc.mode !== "live") throw { forbidden: "the setup record says live" };
    return { channels: ["admin"], grant: { roles: { owner: ["admin"] } } };
  }
  // A check: the owner opens it with its lines, and anyone with the table's link reads it.
  if (doc.type === "check") {
    ctx.requireRole("owner");
    const priced = Array.isArray(doc.lines) && doc.lines.every((l) => typeof l.id === "string" && Number.isInteger(l.price) && l.price > 0);
    if (!priced) throw { forbidden: "each line has an id and a whole-cent price" };
    if (oldDoc && oldDoc.lines.some((l) => !doc.lines.some((m) => m.id === l.id && m.price === l.price))) {
      throw { forbidden: "a check keeps the lines it opened with; add a new line instead" };
    }
    return { channels: [table(doc._id)], grant: { public: [table(doc._id)] } };
  }
  if (doc.mode !== "test" && doc.mode !== "live") throw { forbidden: "money records name their mode" };
  // A payment: /checkout writes it pending for a guest, and only backend.js settles it.
  if (doc.type === "payment") {
    if (oldDoc) {
      ctx.requireBackend(); // /confirm, /release or the sweep, each after reading Stripe
      if (doc.checkId !== oldDoc.checkId || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.mode !== oldDoc.mode || doc.holdId !== oldDoc.holdId) {
        throw { forbidden: "a payment keeps its check, its amount, its mode and its hold" };
      }
    } else {
      ctx.requireBackend("fetch");
      if (doc.status !== "pending" || !Number.isInteger(doc.amount) || !Number.isInteger(doc.createdAt)) {
        throw { forbidden: "a payment starts pending, in whole cents, with the time it was made" };
      }
    }
    table(doc.checkId);
    // allowAnonymous: backend.js writes it while serving a guest who has no account.
    return { channels: ["payments"], grant: { roles: { owner: ["payments"] } }, allowAnonymous: true };
  }
  // The check's ledger in one mode: written by backend.js alone, read by the whole table.
  ctx.requireBackend();
  if (!Number.isInteger(doc.paid) || doc.paid < 0) throw { forbidden: "a ledger counts whole cents" };
  return { channels: [table(doc.checkId)], allowAnonymous: true };
}

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch } from "use-vibes";
import { Badge, Button, Field, Figure, Heading, Input, Metric, Row, Segmented, Stack, Text } from "@vibes.diy/pouf";
import { Checkbox } from "@vibes.diy/pouf/interactive";

const money = (cents) => `$${(cents / 100).toFixed(2)}`;
const post = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const TIPS = [
  { value: "0", label: "No tip" },
  { value: "15", label: "15%" },
  { value: "18", label: "18%" },
  { value: "20", label: "20%" },
  { value: "25", label: "25%" },
];

// Where a check stands in this mode, read off its ledger, which backend.js alone writes.
function useStanding(check, checkId, mode) {
  const { useLiveQuery } = useFireproof("checks");
  const { docs } = useLiveQuery("_id", { key: `ledger:${mode}:${checkId}` });
  const ledger = docs[0];
  const total = check ? check.lines.reduce((n, l) => n + l.price, 0) : 0;
  const paid = Math.min(total, ledger?.paid ?? 0);
  const holds = Object.values(ledger?.holds ?? {});
  const free = Math.max(0, total - paid - holds.reduce((n, h) => n + h.subtotal, 0));
  const taken = new Set([...Object.keys(ledger?.linesPaid ?? {}), ...holds.flatMap((h) => h.lines)]);
  // An even split, once someone picks it, is fixed on the ledger: the next open share's amount.
  const split = ledger?.split;
  const share = (ways) => (split ? (split.shares.find((s) => s.by === null)?.amount ?? 0) : Math.floor(free / ways));
  return { total, paid, left: total - paid, free, taken, paidLines: new Set(Object.keys(ledger?.linesPaid ?? {})), busy: holds.length > 0, split, share };
}

// The setup verdict, read on mount and again whenever the app comes back into view.
function useSetup() {
  const [setup, setSetup] = useState(null);
  const latest = useRef(0);
  useEffect(() => {
    const load = () => {
      const n = ++latest.current;
      apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
    };
    load();
    const onVisible = () => { if (document.visibilityState === "visible") load(); };
    document.addEventListener("visibilitychange", onVisible);
    window.addEventListener("focus", load);
    window.addEventListener("setup-changed", load);
    return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
  }, []);
  return setup;
}

const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };

// The owner's Setup surface: Connect Stripe, the go-live step, and guests' writes for this app.
function SetupScreen({ setup }) {
  const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
  const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
  const item = setup.items.find((i) => i.id === "stripe-connect");
  const live = setup.mode === "live";
  const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
  return (
    <Stack>
      <Heading>Setup</Heading>
      <Row gap={2}>
        <Text>{item.label}</Text>
        <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
      </Row>
      {item.problem && <Text muted>{item.problem}</Text>}
      <ol>
        <li>
          Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
          and press <b>{STRIPE_BUTTON[item.state] ?? "Connect Stripe"}</b> under <b>Payments</b>. Stripe opens its own setup pages.
        </li>
        <li>
          Let guests pay without an account: publishing from the editor turns this on, and after a CLI push run{" "}
          <b>vibes-diy app anon-write {vibe} on</b>. The page never writes for them; this app's server records each payment.
        </li>
        <li>
          Come back here: tables can pay as soon as Stripe says your account is ready. In test mode the card 4242 4242 4242 4242 with any future date pays.
        </li>
        <li>
          <Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
          {live ? (
            <>This app takes real payments into your Stripe account; refunds and payouts are in your Stripe Dashboard.</>
          ) : setup.liveReady ? (
            <>Your live Stripe account is ready. <Button onClick={takeLive}>Take live payments in this app</Button></>
          ) : setup.liveOpen ? (
            <>When a test payment goes through, press <b>Go live with Stripe</b> under <b>Payments</b> on <a href={settingsUrl} target="_blank" rel="noreferrer">the same Settings tab</a>, then come back here to take this app live.</>
          ) : (
            <>Test mode runs the whole flow end to end. The <b>Go live with Stripe</b> button appears under <b>Payments</b> on the same Settings tab when vibes.diy opens live payments.</>
          )}
        </li>
      </ol>
    </Stack>
  );
}

// One open check on the owner's list: its table link as a QR code, and what is paid and left.
function CheckRow({ check, setup }) {
  const s = useStanding(check, check._id, setup.mode);
  const link = `https://vibes.diy/vibe/${setup.ownerHandle}/${setup.appSlug}#check=${check._id}`;
  return (
    <Stack>
      <Row gap={2}>
        <Heading level={3}>{check.table}</Heading>
        {s.left === 0 ? <Badge tone="up">Paid in full</Badge> : <Badge tone="info">{money(s.left)} left</Badge>}
      </Row>
      <Text muted>{money(s.paid)} of {money(s.total)} paid · {link}</Text>
      <Figure src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(link)}`} alt={`QR code for ${check.table}`} width={200} height={200} />
    </Stack>
  );
}

// The owner opens a check: a table name and its lines, prices kept in whole cents.
function Tables({ setup }) {
  const { database, useLiveQuery } = useFireproof("checks");
  const { docs: checks } = useLiveQuery("type", { key: "check" });
  const [table, setTable] = useState("");
  const [lines, setLines] = useState([]);
  const [name, setName] = useState("");
  const [price, setPrice] = useState("");
  const addLine = () => {
    const cents = Math.round(Number(price) * 100);
    if (!name.trim() || !(cents > 0)) return;
    setLines([...lines, { id: crypto.randomUUID().slice(0, 8), name: name.trim(), price: cents }]);
    setName("");
    setPrice("");
  };
  const openCheck = () => {
    const check = { _id: "check-" + crypto.randomUUID(), type: "check", table: table.trim(), lines, currency: "usd", openedAt: Date.now() };
    // The form empties as the owner presses Open; the draft comes back only if the write is refused.
    setTable("");
    setLines([]);
    database.put(check).catch(() => {
      setTable(check.table);
      setLines(check.lines);
    });
  };
  return (
    <Stack>
      <Heading>Open a check</Heading>
      <Field label="Table"><Input value={table} onChange={(e) => setTable(e.target.value)} placeholder="Table 4" /></Field>
      <Row gap={2}>
        <Field label="Item"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Margherita" /></Field>
        <Field label="Price"><Input value={price} onChange={(e) => setPrice(e.target.value)} placeholder="14.50" inputMode="decimal" /></Field>
        <Button variant="quiet" onClick={addLine}>Add item</Button>
      </Row>
      {lines.map((l) => <Text key={l.id}>{l.name} · {money(l.price)}</Text>)}
      <Button onClick={openCheck} disabled={!table.trim() || lines.length === 0}>Open the check</Button>
      {checks.map((c) => <CheckRow key={c._id} check={c} setup={setup} />)}
    </Stack>
  );
}

const SORRY = {
  "being-paid": "Someone at the table is paying right now. This page updates the moment they finish.",
  "paid-in-full": "This check is paid in full.",
  taken: "Someone is paying for one of those. Pick again from what is left.",
  "mode-changed": "Payments here just changed mode. Refresh the page and press Pay again.",
  "try-again": "Stripe did not answer just now. Press Pay again in a moment.",
};

// The table's page: one button pays everything that is left, and splitting is there for the table that wants it.
function TableCheck({ checkId, setup }) {
  const { useLiveQuery } = useFireproof("checks");
  const { docs: found } = useLiveQuery("_id", { key: checkId });
  const check = found.find((d) => d.type === "check");
  const s = useStanding(check, checkId, setup?.mode);
  const [splitting, setSplitting] = useState(false);
  const [how, setHow] = useState("lines");
  const [picked, setPicked] = useState([]);
  const [ways, setWays] = useState("2");
  const [tip, setTip] = useState("18");
  const [mine, setMine] = useState(null); // this payer's latest payment: { id, state }
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState(null);

  // Back from Stripe: /confirm settles the payment by what Stripe says, and the ledger moves for everyone.
  useEffect(() => {
    const back = () => {
      const h = new URLSearchParams(location.hash.slice(1));
      const paid = h.get("paid");
      const cancelled = h.get("cancelled");
      if (!paid && !cancelled) return;
      history.replaceState(null, "", `${location.pathname}#check=${checkId}`);
      if (cancelled) {
        apiFetch("/release", post({ paymentId: cancelled }));
        setMine(null);
        return;
      }
      setMine({ id: paid, state: "pending" });
      apiFetch("/confirm", post({ paymentId: paid })).then((r) => r.json()).then((d) => setMine({ id: paid, state: d.state ?? "pending" }));
    };
    back();
    window.addEventListener("hashchange", back);
    return () => window.removeEventListener("hashchange", back);
  }, [checkId]);

  const withTip = (cents) => cents + Math.round((cents * Number(tip)) / 100);
  // `quote` is the subtotal this page shows; the backend charges exactly that or answers with the new one.
  const pay = async (what) => {
    setError(null);
    const tab = window.open("", "_blank"); // opened inside the click, before any await
    if (!tab) {
      setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and press Pay again.");
      return;
    }
    setBusy(true);
    try {
      const replaces = mine?.state === "pending" ? mine.id : undefined;
      const res = await apiFetch("/checkout", post({ checkId, ...what, tipPercent: Number(tip), replaces, mode: setup.mode }));
      const data = await res.json();
      if (!data.ok) {
        tab.close();
        setError(
          data.reason === "stripe" ? data.message
          : data.reason === "quote-changed" ? `The check just changed: your part is now ${money(withTip(data.quote))}. Press Pay again to pay that.`
          : SORRY[data.reason] ?? "Checkout is not open yet."
        );
        return;
      }
      setMine({ id: data.paymentId, state: "pending" });
      tab.location.href = data.url;
    } finally {
      setBusy(false);
    }
  };

  if (!check) return <Text muted>Finding this table's check…</Text>;
  const open = check.lines.filter((l) => !s.taken.has(l.id));
  const pickedTotal = open.filter((l) => picked.includes(l.id)).reduce((n, l) => n + l.price, 0);
  const share = s.share(Number(ways));
  return (
    <Stack>
      <Heading>{check.table}</Heading>
      <Row gap={2}>
        <Metric label="Total" value={money(s.total)} />
        <Metric label="Paid" value={money(s.paid)} />
        <Metric label="Left" value={money(s.left)} />
      </Row>
      {check.lines.map((l) => (
        <Row key={l.id} gap={2}>
          <Text>{l.name}</Text>
          <Text num>{money(l.price)}</Text>
          {s.paidLines.has(l.id) && <Badge tone="up">Paid</Badge>}
        </Row>
      ))}
      {mine?.state === "paid" && <Badge tone="up">Paid. Thank you!</Badge>}
      {s.busy && mine?.state !== "pending" && <Text muted>Someone at the table is paying right now.</Text>}
      {s.left === 0 ? (
        <Badge tone="up">Paid in full</Badge>
      ) : !setup?.ready ? (
        <Text muted>Checkout is not open yet.</Text>
      ) : (
        <Stack>
          {setup.mode === "test" && (
            <Row gap={2}>
              <Badge tone="info">Test mode</Badge>
              <Text muted>{setup.owner ? "Pay with the test card 4242 4242 4242 4242 and any future date." : "This is a rehearsal: test payments end when the app goes live."}</Text>
            </Row>
          )}
          <Segmented label="Tip" value={tip} onChange={setTip} options={TIPS} />
          {!splitting ? (
            <Stack>
              <Button onClick={() => pay({ pay: "rest", quote: s.free })} disabled={busy || s.free === 0} loading={busy}>
                Pay {money(withTip(s.free))} — Stripe opens in a new tab
              </Button>
              <Button variant="quiet" onClick={() => setSplitting(true)}>Split the check</Button>
            </Stack>
          ) : (
            <Stack>
              <Segmented label="Split by" value={how} onChange={setHow} options={[{ value: "lines", label: "What I had" }, { value: "share", label: "Even shares" }]} />
              {how === "lines" ? (
                <Stack>
                  {open.map((l) => (
                    <Checkbox key={l.id} label={`${l.name} · ${money(l.price)}`} checked={picked.includes(l.id)} onChange={(on) => setPicked(on ? [...picked, l.id] : picked.filter((id) => id !== l.id))} />
                  ))}
                  <Button onClick={() => pay({ pay: "lines", lineIds: picked, quote: pickedTotal })} disabled={busy || pickedTotal === 0} loading={busy}>
                    Pay {money(withTip(pickedTotal))} for what I had
                  </Button>
                </Stack>
              ) : (
                <Stack>
                  {s.split ? (
                    <Text muted>This check is split {s.split.ways} ways.</Text>
                  ) : (
                    <Segmented label="Ways" value={ways} onChange={setWays} options={["2", "3", "4", "5", "6"].map((n) => ({ value: n, label: `${n} ways` }))} />
                  )}
                  <Button onClick={() => pay({ pay: "share", ways: Number(ways), quote: share })} disabled={busy || share === 0} loading={busy}>
                    Pay my share, {money(withTip(share))}
                  </Button>
                </Stack>
              )}
              <Button variant="quiet" onClick={() => setSplitting(false)}>Pay the whole check instead</Button>
            </Stack>
          )}
        </Stack>
      )}
      {error && <Text>{error}</Text>}
    </Stack>
  );
}

export default function App() {
  const setup = useSetup();
  const [hash, setHash] = useState(() => location.hash);
  const [screen, setScreen] = useState("tables");
  useEffect(() => {
    const onHash = () => setHash(location.hash);
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);
  const checkId = new URLSearchParams(hash.slice(1)).get("check");
  if (checkId) return <TableCheck checkId={checkId} setup={setup} />;
  if (!setup?.owner) return <Text muted>Scan the code on your table to see your check and pay.</Text>;
  return (
    <Stack>
      {/* The owner's own screens: the tables, and Setup while an item is still to do. */}
      <Row gap={2}>
        <Button variant="quiet" onClick={() => setScreen("tables")}>Tables</Button>
        {setup?.owner && (
          <Button variant={setup.ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
            Setup{setup.ready ? "" : " · to do"}
          </Button>
        )}
      </Row>
      {screen === "setup" && setup?.owner ? <SetupScreen setup={setup} /> : <Tables setup={setup} />}
    </Stack>
  );
}

A tip jar or a donation page keeps this shape with one standing check the owner opens once and amounts the backend prices from the page's own list, and no split. Staff who are not the owner open checks through a staff role, the way the access docs teach roles.