Refund a guest deposit or a card-on-file no-show fee, in full or in part (admin)
/api/payments/refundsWhat 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).
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
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/payments/refunds" \ -H "Content-Type: application/json" \ -d '{ "paymentIntentId": "string" }'{ "ok": true, "refundId": "string", "amountCents": 0, "fullyRefunded": true, "depositStatus": "string", "cardOnFileStatus": "string"}Re-sync billing from Stripe, then redirect to Organization settings GET
Where 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.
The Takings report: gross, refunded and net Stripe-collected money over a date range GET
Sums book_payment_events (0062) for this company between `from` and `to`, inclusive, UTC calendar dates. **Stripe-collected money only**: deposits and no-show fees, both of which land here the instant they succeed or get refunded. A close-out sale paid in cash, on the venue's own card machine or by bank transfer moves no Stripe money and writes no row here, so none of that is in this total (see `lib/billing/takings.ts`). Readable by any member, the same gate the Transactions tab this report summarises already uses: it is an aggregate of data that tab already shows in full to anyone who can reach `/payments`. `format=csv` returns the same range as a downloadable, line-per-event file instead of the JSON summary.