Loyalty Cash Credit — Overview

v1

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.

Three surfaces
Each surface is independently toggleable per tenant.
booking_direct — booking engine widget on the hotel's own site. Member sees an "Apply X points (€Y off)" chip and the cash credit is held while they complete the booking flow. No reservation required at hold time.
in_stay — guest portal or in-stay captive page. Member picks an amount and generates a one-shot credit code applied to folio charges. Reservation must already exist.
checkout — front-desk terminal or self-checkout kiosk. Operator (or guest) types in a member number and applies a short-lived hold against the bill total.
Channels — OTA/TTOO are blocked
The denylist runs before the allowlist.

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.

Hold lifecycle
A hold is the "soft reservation" of points while the customer completes their flow.
      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.changed

Holds 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 codes
Returned in the standard envelope { error: { code, message } }.
CodeHTTPMeaning
CASH_CREDIT_DISABLED403Tenant has cash credit turned off.
SURFACE_DISABLED403Surface (booking_direct / in_stay / checkout) is toggled off for this tenant.
CHANNEL_NOT_ELIGIBLE403OTA denylist hit, or channel missing from allowed_channels.
INSUFFICIENT_SPENDABLE409points requested exceeds loyalty_member_spendable().
BELOW_MINIMUM422points below cash_credit_config.min_points_per_redemption.
HOLD_EXPIRED410Confirm attempted after the hold's expires_at.
HOLD_NOT_FOUND404No hold for the provided hold_id / external_reference_id.
MEMBER_NOT_FOUND404No loyalty member matched the supplied identifier.
VALIDATION_ERROR400Zod schema rejected the payload. Offending field returned.