Shared

Void a sale

post/api/sales/{id}/void

Reverses the sale's stock ONLY (book_void_sale(), reason sale_undo) and marks it voided: it does not un-collect any payment or reverse a gift-card redemption, the same "no undo here, correct it the way any other mistake is corrected" posture GiftCardRedeemField's own redemption already takes. No hasPermission gate, matching POST /api/sales.

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

Path Parameters

id*string

Sale id.

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

curl -X POST "https://example.com/api/sales/string/void" \  -H "Content-Type: application/json" \  -d '{}'
{  "sale": {    "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",    "company_id": "b2e6a1c3-1a5e-44ae-a8fd-81f76fd715cf",    "booking_table": "book_appointments",    "booking_id": "b0ae0641-0cd4-4f7f-8550-dcd550941f4a",    "customer_id": "160c0c4b-9966-4dc1-a916-8407eb10d74e",    "subtotal_cents": 0,    "discount_type": "percent",    "discount_value": 0,    "discount_cents": 0,    "total_cents": 0,    "gift_card_applied_cents": 0,    "tip_cents": 0,    "paid_in_person_cents": 0,    "payment_method": "cash",    "status": "completed",    "void_reason": "string",    "voided_at": "2019-08-24T14:15:22Z",    "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",    "created_at": "2019-08-24T14:15:22Z",    "sold_by_provider_id": "0aff90c0-8294-4110-8a4e-bbd4f49eb7ff"  }}

Record a sale POST

The shared "Add item" cart BeNotely uses for both a standalone walk-in "New sale" (no `bookingTable`/`bookingId`) and a booking's close-out (both set, as a pair: CloseOutDialog's own POST). No `hasPermission` gate: recording a sale is ordinary front-of-house work, the same posture PATCH /api/appointments/{id} and /api/reservations/{id} already take for closing out a booking. Calls `book_record_sale()` (service_role), which atomically inserts the sale and its line items and, for each product line, decrements stock via `book_product_stock_adjust()`: either the whole sale and every stock delta commit, or none do.

Live client search for CustomerPicker GET

Backs the new-booking/new-reservation forms' client picker once a search term is typed (added 2026-08-30, audit fix). The picker's own initial page (fetched server-side by whichever dashboard page rendered it) is capped at 500 rows and filtered client-side for instant feedback on a small roster; past that cap a real client is otherwise unreachable, so this route exists as the live fallback once `q` is at least 2 characters. **Appointments**: confined the same way the page-level picker list already is, via `writeScope()` (team-scope.ts): a scope-limited staff/manager login may only find a client they have at least one `provider_confirmed` appointment with. **Hospitality**: unconfined for every role, matching the reservations page's own unscoped list, since hospitality has no provider concept to confine by. Redaction (`staff_client_visibility`) is applied for staff/manager on the appointments side, matching the page-level list exactly. Hospitality's own page-level list has never applied this redaction either, preserved as-is here rather than changed in passing. Below 2 characters this returns an empty list rather than scanning: the picker's own local filter over its already-loaded page covers "just started typing" instantly.