Shared

Preview the suggested tier amount for a booking (admin)

get/api/payments/no-show-fee

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.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

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

bookingTable*string

Value in

  • "book_appointments"
  • "book_reservations"
bookingId*string

Response Body

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/api/payments/no-show-fee?bookingTable=book_appointments&bookingId=string"
{  "suggestedTierCents": 0}

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.

Charge a card-on-file no-show fee (admin) POST

What the "Charge fee" action on `/payments`, a booking's own detail dialog, and the reservations/bookings lists all call. The one MANUAL step in the whole card-on-file mechanism (migration 0129): every route upstream of this one only ever SAVES a card at zero charge (`createSetupIntent`, the guest checkout routes), and nothing is ever charged until a human clicks the button that POSTs here, on a no-show or a late cancellation staff decided is chargeable. There is no cron, no trigger, nothing on a timer. **A direct charge on the connected account**, off-session against the card saved at booking. The fee amount snapshotted into `deposit_amount_cents` at booking time (the same column a real deposit uses) is the CEILING; a later policy change never moves it. **`amountCents` (migration 0137) is optional and, when sent, is clamped server-side** against that same `deposit_amount_cents` before it is trusted for anything: strictly greater than 0 and no larger than the deposit. Omitted or null keeps the pre-0137 behaviour exactly: the full frozen deposit, charged unconditionally. This is what lets the dialog charge less than the disclosed ceiling for a booking whose own cancellation notice earned a lighter tier (see `GET` above), while a suggested figure or any custom amount a staff member types is never trusted on the client's word alone. **An `Idempotency-Key` header is REQUIRED**, identical in shape to `/api/payments/refunds`: `withIdempotency` runs the route at most once per key and replays the first response, the same key goes to Stripe as its own idempotency option, and the ledger row carries the charged PaymentIntent id in `stripe_event_id` (this charge's own webhook delivery is a deliberate no-op, so there is no separate `evt_...` to dedupe against). **A fourth layer this route needs that refunds does not**: a compare-and-set claim (`card_on_file_status` → `'charging'`) *before* Stripe is ever called. A refund is safe from two racing keys because Stripe itself is the ceiling (a second refund attempt fails outright); a charge has no such ceiling: two off-session PaymentIntents against the same saved card, carrying two different keys, would both succeed at Stripe and charge the guest twice. The claim closes that window; a losing request never reaches Stripe at all. 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`). Not engine-gated: `card_on_file_enabled` lives on `book_companies` for either vertical, and both booking tables carry the 0129 columns this route reads and writes.