Loyalty Cash Credit — Overview
Let members spend their points as cash credit on direct bookings, in-stay folio charges, and at checkout. Every redemption goes through a single Quote → Hold → Confirm/Release lifecycle backed by atomic SQL primitives.
Allowed channels are drawn from direct, website, phone, walk_in. Tenants opt in to whichever apply.
The following sources are always blocked, regardless of allowed_channels: booking.com, expedia, agoda, hotelbeds, hotusa, roiback, paraty, mirai, travelclick, siteminder, ota, tour_operator, ttoo.
Rationale: those bookings already carry commission and applying points credit on top is a double-cost the hotel didn't sign up for.
POST /credit/quote
│ (read-only, no DB write)
▼
POST /credit/hold ─────────► loyalty.credit.held
│ (active, expires in hold_minutes)
│
┌───────┼────────┐
│ │ │
▼ ▼ ▼
confirm release expire (cron)
│ │ │
▼ ▼ ▼
loyalty loyalty loyalty
.credit .credit .credit
.confirmed .released .released (cause=expired)
│
▼
loyalty.balance.changed
│
│ (cancellation path)
▼
POST /credit/reverse ─────────► loyalty.credit.reversed
loyalty.balance.changedHolds default to 10 minutes (booking_direct) or 5 minutes (kiosk/front desk). The cronexpire_loyalty_credit_holdssweeps overdue actives on a schedule and emits oneloyalty.credit.releasedwebhook per expired hold (cause = expired).
{ error: { code, message } }.| Code | HTTP | Meaning |
|---|---|---|
| CASH_CREDIT_DISABLED | 403 | Tenant has cash credit turned off. |
| SURFACE_DISABLED | 403 | Surface (booking_direct / in_stay / checkout) is toggled off for this tenant. |
| CHANNEL_NOT_ELIGIBLE | 403 | OTA denylist hit, or channel missing from allowed_channels. |
| INSUFFICIENT_SPENDABLE | 409 | points requested exceeds loyalty_member_spendable(). |
| BELOW_MINIMUM | 422 | points below cash_credit_config.min_points_per_redemption. |
| HOLD_EXPIRED | 410 | Confirm attempted after the hold's expires_at. |
| HOLD_NOT_FOUND | 404 | No hold for the provided hold_id / external_reference_id. |
| MEMBER_NOT_FOUND | 404 | No loyalty member matched the supplied identifier. |
| VALIDATION_ERROR | 400 | Zod schema rejected the payload. Offending field returned. |