Shared

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

post/api/payments/no-show-fee

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.

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

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

application/json

curl -X POST "https://example.com/api/payments/no-show-fee" \  -H "Content-Type: application/json" \  -d '{    "bookingTable": "book_appointments",    "bookingId": "string"  }'
{  "ok": true,  "paymentIntentId": "string",  "amountCents": 0,  "suggestedTierCents": 0}

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.

Resend a receipt or tax invoice (admin) POST

What the Resend button on the Transactions tab calls. A receipt is minted once, automatically, the moment a deposit is confirmed paid (the Stripe webhook's `issueReceipt`, migration 0126); this route re-sends the SAME numbered document rather than generating a new one, so the receipt number never changes and the email never gets a second entry in the venue's own sequence. **Synchronous, not queued.** The automatic send at issue time goes through the background worker (`receipt_send`, off the webhook's response path), but a caller who clicks this button wants an answer now, the same distinction `POST /api/payments/refunds` already draws between the two. **Always actually re-sends**, even if one already went out: the idempotency key used here is a fresh one per call rather than the stable per-receipt key the automatic path uses, because reusing that key would make Resend's own request-level dedupe silently swallow the second send. Clicking "Resend" and mailing nobody is the one failure mode this route cannot have. Admin-only. No `Idempotency-Key` header requirement, unlike refunds: a duplicate resend costs a nuisance email, not a second charge out of the business's balance, so there is nothing here worth making a caller retry a whole request over.