# Demo 3: Booking with Checkout

**Port:** 3003 · **Postgres schema:** `booking` (migration slot `2026102403xxxx`) · **Services:** Stripe Connect (test mode), Supabase

A customer pays for a held bike or court. The payment turns the capacity engine's hold into a confirmed booking, the money goes to the business, and the platform keeps a 10% fee. Two providers sit behind one API:

- **Sandbox (default, no keys):** a hosted-style payment page that says **Sandbox payment · no money moves**. It behaves like Stripe Checkout with Stripe's test cards.
- **Stripe test mode:** a real Checkout Session as a destination charge to the business's connected account. It is used when the runtime has `STRIPE_SECRET_KEY` and the business has a connected account.

## Demo moment (under 60 seconds)

1. Open `/demos/booking-checkout/`, choose **Spin Mitte**, click **Hold the next free Spin 45**. The capacity engine places a hold on a bike. Click **Continue to payment**. (The public booking site `/b/spin-mitte` calls the same API.)
2. The payment page shows the order and the split: **Spin Mitte receives €16.20, platform fee €1.80**.
3. Click the test card **4000 0000 0000 0002**, then **Pay €18.00**: "Your card was declined." The booking stays open.
4. Click **4242 4242 4242 4242**, then **Pay**. The page says Paid and returns to the confirmation: **Booked and paid**. The staff calendar (`/demos/capacity-engine/internal.html`) shows the bike as booked, not held.
5. With Stripe keys set up, the same steps open Stripe Checkout. In the Stripe dashboard the payment appears on Spin Mitte's connected account, and the platform's application fee is listed under Collected fees.

## How the money flows

```
Customer pays €18.00 ──► Platform (Stripe Checkout, destination charge)
                           ├─ €16.20 transferred to the business's connected account
                           └─ €1.80 application fee kept by the platform
```

- The amount comes from the business config on the server (`offerings[].price`, in cents), never from the client. A request's `amount` is ignored.
- Fee: `round(amount × 10%)`. Spin 45: €18.00 → €1.80. Padel court: €40.00 → €4.00.
- With destination charges Stripe's processing fee is charged to the platform, out of its €1.80. The business receives the full €16.20.
- Refund (unit taken after payment): `POST /v1/refunds` with `reverse_transfer` and `refund_application_fee`, so the business's transfer and the platform fee are reversed.

## Holds and payment windows

The capacity engine holds a unit for `booking_rules.hold_minutes` (15 for Spin Mitte, 10 for Padel Ost). A Stripe Checkout Session lives for at least 30 minutes; sessions here expire after 31. Between the end of the hold and the end of the session the customer can still pay:

- On payment, `booking.settle()` calls `confirm_claim`. It succeeds if the unit is still free (late confirmation).
- If someone else took the unit (`23P01`) or the hold was released (`55000`), the booking becomes **failed**. Stripe refunds in full; the sandbox reports that no money moved.
- The pay page and Stripe Checkout (submit message) show how long the unit is held.
- A new checkout needs a hold that is pending and not expired. A second checkout for the same hold by the same email returns the first one.

## Test cards (sandbox)

Any future expiry date and any 3-digit CVC.

| Card | Result |
|---|---|
| 4242 4242 4242 4242 | Succeeds |
| 4000 0000 0000 0002 | Declined (`card_declined`, `generic_decline`) |
| 4000 0025 0000 3155 | Asks for authentication: a simulated 3D Secure step, **Complete** succeeds, **Fail** declines |
| 4000 0000 0000 9995 | Declined, insufficient funds |
| 4000 0000 0000 0069 · 0127 | Expired card · incorrect CVC |
| 5555 5555 5555 4444 | Succeeds (Mastercard) |

The page checks the number before sending it: anything that is not a Stripe test card stays in the browser.

## API (runtime, `runtime/src/routes/checkout.ts`)

| Route | Purpose |
|---|---|
| `POST /api/checkout/sessions` `{ business, claim_id, offering, start, resource, customer: { name, email }, return_url, cancel_url }` | → `201 { booking_id, url, provider: "stripe" \| "simulated" }` plus `amount`, `platform_fee`, `currency`, `expires_at`, and `note` when Stripe was not used. `{BOOKING_ID}` in the URLs is replaced. URLs must be on the same site or localhost. |
| `GET /api/checkout/bookings/:id` | `{ booking: { id, business, status, amount, currency, platform_fee, provider, start, resource, customer_name, … } }`. Status: `pending`, `paid`, `failed`, `expired`, `cancelled`. For a pending Stripe booking it retrieves the Checkout Session first (webhook fallback). |
| `POST /api/checkout/bookings/:id/cancel` | Cancel a pending booking: expires the Stripe session, releases the hold. → `{ booking, redirect }` |
| `GET /api/checkout/simulated/:session` · `POST …/:session/pay` | Sandbox page data; payment with `{ number, exp_month, exp_year, cvc, three_d_secure? }` → `paid` / `requires_action` / `failed`, or 402 decline, 400 invalid card |
| `POST /api/checkout/stripe/webhook` | Verifies `Stripe-Signature` (HMAC-SHA256, Web Crypto, 5-minute tolerance). Handles `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.expired`. |
| `GET /api/checkout/stripe/return` · `…/cancel` | Checkout's success and cancel URLs: reconcile or cancel, then redirect to the client's `return_url` / `cancel_url` |
| `GET /api/checkout/:business/payments?limit=50` | Owner billing view: recent bookings with amount, fee, business share, provider, status; totals of paid bookings; connected account. Emails are masked. |
| `GET /api/checkout/config` | Fee rate, storage mode, whether Stripe and the webhook secret are configured |

Errors are `{ error: { code, message } }`: `invalid_request` 400, `not_payable` 400, `business_not_found` / `hold_not_found` / `booking_not_found` 404, `hold_mismatch` / `hold_unavailable` / `hold_expired` / `hold_in_checkout` / `already_paid` / `session_closed` 409, `storage_unavailable` 503.

## Switch to Stripe test mode

1. Stripe test account with Connect enabled. Add `STRIPE_SECRET_KEY=sk_test_…` to `runtime/.secrets.env`. Live keys are refused by the runtime and the script.
2. Create the connected accounts and the webhook endpoint:

   ```sh
   node demos/booking-checkout/scripts/stripe-setup.mjs --env-file runtime/.secrets.env \
     --webhook-url https://hackathon.fund-bench.com/api/checkout/stripe/webhook
   ```

   For each business with a priced offering (Spin Mitte, Padel Ost) it finds the account by the id stored in `booking.payment_accounts` or by `metadata.platform_business`, otherwise creates a platform-managed Custom-style account (DE, individual) filled with Stripe's test data (DOB 1901-01-01, `address_full_match`, test document, test IBAN `DE89…`, ToS acceptance). It waits until `transfers` is active and saves the id. A new webhook endpoint's signing secret is written to the env file as `STRIPE_WEBHOOK_SECRET`. `--dry-run` shows what it would do.
3. Deploy the runtime with the secrets file. `POST /api/checkout/sessions` now answers `provider: "stripe"` for those businesses.
4. Locally, forward webhooks with the Stripe CLI: `stripe listen --forward-to localhost:5173/api/checkout/stripe/webhook`, and use the `whsec_…` it prints. Without the webhook, the return URL and `GET /api/checkout/bookings/:id` confirm from the session.

Optional `STRIPE_CONNECTED_ACCOUNTS={"spin-mitte":"acct_…"}` on the runtime names accounts when Supabase has no row.

## Run

```sh
cd runtime && npm install && npx cf dev            # then http://localhost:5173/demos/booking-checkout/
cd demos/booking-checkout && npm test              # node:test, no dependencies (Node 22.18+ for the .ts imports)
```

Without Supabase keys the runtime keeps bookings in the Worker's memory and cannot see holds (the page then uses a local claim id); with them, bookings are stored in `booking.bookings` and holds are checked, confirmed and released in Postgres. A static server alone cannot run this demo: the pay page needs the API.

## Database

[`20261024030000_booking_checkout.sql`](../../supabase/migrations/20261024030000_booking_checkout.sql), applied to the hackathon project `sejwjuwopnwupcdbexhi` with `booking` exposed.

- `booking.bookings`: status, amount and fee in cents, currency, provider, Checkout Session id and URL, payment intent, connected account, claim id, customer name and email, slot, hold and session expiry, refund id, timestamps. One live (pending or paid) booking per claim.
- `booking.payment_accounts`: one Stripe connected account per business with charges, payouts and transfers status.
- `booking.settle(booking, payment_intent)`: in one transaction, `capacity.confirm_claim` → `paid`, or `failed` when the unit was taken or the hold is gone. `booking.close(booking, status, reason)`: `capacity.release_claim` → `cancelled` or `expired`. Both only change bookings that are still open, so webhook retries are harmless.
- RLS is on with no anon or authenticated policies. The runtime uses the secret key (service role) server side; the confirmation page and the owner view read through the API.
- Holds are checked by reading `capacity.interval_claims` with the secret key (read only), because the capacity engine has no RPC that returns one claim.

## Simulated

- The sandbox provider: no Stripe call, no money. Card outcomes follow Stripe's test cards; 3D Secure is a dialog. A sandbox refund is reported, not made.
- Connected accounts are filled with Stripe's test identity data; nobody is verified.
- No auth: anyone with a booking id can read or cancel a pending booking, and the owner view is open, as in the other demos.

## Files

| Path | What |
|---|---|
| [`pay.html`](pay.html), [`pay.js`](pay.js) | Sandbox payment page |
| [`index.html`](index.html), [`checkout.js`](checkout.js) | Hold → pay starter, order form for the capacity engine's link, confirmation page |
| [`cards.js`](cards.js) | Test cards, shared by the page and the runtime |
| [`ui.js`](ui.js), [`styles.css`](styles.css) | Business theme on the shared design system, formatting |
| [`scripts/stripe-setup.mjs`](scripts/stripe-setup.mjs) | Connected accounts and webhook endpoint (test mode) |
| [`test/`](test) | Flows with mocked Stripe, webhook signatures, store requests, setup script, cards |
| [`../../runtime/src/routes/checkout.ts`](../../runtime/src/routes/checkout.ts), [`checkout/`](../../runtime/src/routes/checkout) | API, service, Stripe client, storage |

## Done when

- [x] A sandbox payment succeeds and the booking is confirmed; declines and the authentication step behave like Stripe's test cards; cancel releases the hold.
- [x] Paying after the unit was taken marks the booking failed (refund in Stripe mode).
- [x] Stripe path covered by tests with a mocked Stripe API: destination charge, application fee, metadata, signed webhook, return-page fallback, cancel, expiry, refund.
- [ ] A Stripe test payment lands on a connected account with the application fee (needs keys and `stripe-setup.mjs`).

## Out of scope

Reschedule, cancellation refunds after payment, emails, customer accounts, `units_per_booking > 1`.
