The Takings report: gross, refunded and net Stripe-collected money over a date range
/api/payments/takingsSums 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.
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
UTC calendar date, YYYY-MM-DD, inclusive.
dateUTC calendar date, YYYY-MM-DD, inclusive. Must not be before from, and the range cannot exceed five years.
dateDefaults to json. csv returns Content-Type: text/csv with Content-Disposition: attachment instead of the summary below.
Value in
- "json"
- "csv"
Response Body
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/payments/takings?from=2019-08-24&to=2019-08-24"{ "summary": { "grossCents": 0, "refundedCents": 0, "netCents": 0, "gstCents": 0, "paymentCount": 0 }, "currency": "string", "rows": [ { "id": "string", "createdAt": "2019-08-24T14:15:22Z", "type": "initiated", "amountCents": 0, "reason": "string" } ]}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`).
Preview the suggested tier amount for a booking (admin) GET
Read-only preview the no-show fee dialog fetches before it ever opens, so its "Full amount" tab can pre-fill with the tier this booking's own cancellation notice actually earned (migration 0137, `book_no_show_fee_tiers`) rather than always the frozen deposit. No charge, no idempotency key, nothing written. `suggestedTierCents` resolves the strictest configured tier whose window this booking's notice (`starts_at` minus `cancelled_at`) falls inside; among several qualifying tiers the tightest (smallest `thresholdHours`) wins. A booking with no `cancelled_at` recorded (a genuine no-show, or a legacy pre-0137 cancellation) or an explicit `no_show` status instead falls back to the single strictest tier configured, so a no-show can never suggest less than an actual late cancellation would. Null when the company has configured no tiers, or the booking has no positive `deposit_amount_cents` to suggest a share of: in both cases the dialog falls back to today's exact behaviour, the full frozen deposit.