Re-sync billing from Stripe, then redirect to Organization settings
/api/organization/stripe/successWhere a tier checkout, an add-on checkout, AND the Stripe Billing Portal all send an admin back, before they ever reach /organization. Never grants anything itself: syncCompanyBilling() (src/lib/billing/sync.ts) only re-reads what the webhook already decided, a beat earlier than the webhook might otherwise get to it, so an admin who just paid (or just cancelled in the portal) does not land on a page still showing the state from before they clicked. The webhook (handleTierBought/handleAddonBought/etc.) remains the only thing that actually writes an entitlement.
Fails open on every edge on purpose: no session, a company with no Stripe customer yet, or the sync call itself throwing all fall through to the same redirect rather than stranding an admin on an error page after they have already paid. TierCard/AddonBar's own delayed re-read after the redirect is the second safety net if even this proves too early; the webhook can genuinely still be in flight.
Authorization
sessionCookie The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across
numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie
locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified
app_metadata.company_id claim and enforced by row-level security; it is never read from request
input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code,
the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's
own check is the enforcement.
In: cookie
Query Parameters
Echoed back onto the redirect as ?tier=&result=active when a tier checkout is what sent the browser here.
Echoed back onto the redirect as ?addon=&result=active when an add-on checkout (or the billing portal) is what sent the browser here.
Response Body
curl -X GET "https://example.com/api/organization/stripe/success"Start or resume Stripe Connect onboarding (admin) POST
Returns a single-use hosted onboarding URL to redirect the operator to. Minted fresh on every call rather than stored, because a cached link 404s the second time it is clicked. Idempotent in the way that matters: an org that already has an account gets a link to **that** account, and `country` is not read at all on that path. Two connected accounts for one venue is not a state this app could later untangle; the second would hold real charges. Account creation uses the Stripe **v2** API (`stripe.v2.core.accounts`). The v1 `accounts.create({ type: "express" })` path is hard blocked on this platform account and fails in a way that reads like a permissions problem; v2 is also Stripe's current recommendation for new integrations. Accounts are created with `dashboard: "full"` and fees/losses collected by Stripe, which makes each venue the merchant of record for its own bookings; guest money settles in their balance and never passes through the platform, and no application fee is taken. `country` is asked for rather than derived. A connected account's country is permanent and decides which payout rails and legal terms apply, and `book_companies.country` holds a display name written by the onboarding wizard, not an ISO code; guessing it from the currency would put a Dublin restaurant charging EUR into France.
Refund a guest deposit or a card-on-file no-show fee, in full or in part (admin) POST
What the Refund button on `/payments` calls. Until this existed a refund only ever happened as a **side effect of cancelling a booking** (`resolveDepositOnCancel`), so a business returning part of a deposit on a booking it was still honouring had to do it from its own Stripe dashboard. This is a separate route from the cancel paths deliberately: those refund because a booking went away, this one refunds because a human decided an amount. **Two refundable tracks share one payment intent (migration 0129).** A real deposit marks `deposit_status = 'paid'`; a card-on-file no-show fee never touches that column and marks `card_on_file_status = 'charged'` instead. A booking is never both, so exactly one of the two response fields below reflects a state this call actually changed and the other simply reports the untouched value. **A direct refund on the connected account.** Either kind of payment is a direct charge, so the money leaves the business's own Stripe balance, never the platform's, and no application fee is unwound because none was ever taken. `amountCents` omitted means the whole remaining balance, which is not the same as sending the amount the ledger thinks is outstanding: **Stripe holds the real running total**, so "everything left" is always right, while a computed figure can be short by any refund the Connect webhook never mirrored back. A partial refund is bounded locally by what the ledger's own `succeeded` row for this exact payment intent recorded (not the booking's frozen deposit snapshot, which a tiered no-show fee can charge less than); Stripe is the authority on what is actually left, and its own message comes back verbatim at 400 when the amount exceeds it. **An `Idempotency-Key` header is REQUIRED**, unlike the two guest checkout routes where an absent header means "no idempotency requested" so an older client bundle keeps working. There is no older client here, and a refund with no guard is not worth being permissive about. Three layers hang off that one key: `withIdempotency` runs the route at most once per key and replays the first response (the double-clicked Confirm); the same key goes to Stripe as its own idempotency option, covering the window our table cannot, since it deletes its key row on a thrown error so a transient failure stays retryable (a crash after Stripe refunded and before the response was sent); and the ledger row carries the **refund** id in `stripe_event_id`, whose unique index (migration 0062) stops that replay writing a second timeline entry for one refund. Every other writer puts an `evt_...` there and this one puts `re_...`, which cannot collide. **The winning column moves only on a FULL refund, only from its charged value**, exactly the rule the `charge.refunded` webhook handler already applies to both tracks. A partial leaves it at its charged value, which is what admits a second partial later. The update is a compare-and-set that counts the rows it changed, because a zero-row PostgREST update answers 204 rather than an error: `charge.refunded` can arrive from Stripe while this request is in flight, and the loser must not report itself the winner. The ledger row is written here rather than left to that webhook, which writes one too. The webhook is the mirror for a refund taken in the business's own Stripe dashboard, and relying on it alone would mean a refund issued from this screen might not show up on the screen that issued it until that delivery lands; the idempotency layer above is what keeps the two from double-counting when both do write. Not engine-gated: 0012 put the deposit policy on `book_companies` for a salon's colour service as much as for a Saturday night, and both booking tables can carry a paid deposit or a charged no-show fee. Admin-only, and that gate is the whole authorization: the button is hidden from a `staff` login, but hiding a button is cosmetic (see `src/lib/auth/require-member.ts`).