Skip to main content

Booking Lifecycle

The booking system manages tee time reservations through a state machine with holds, confirmations, cancellations, and rescheduling.

Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ POST /hold │────▶│ PENDING │────▶│ POST /confirm │
│ (create hold) │ │ (slot held) │ │ (finalize) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ DELETE │ │ CONFIRMED │
│ (release) │ │ (booked) │
└──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ CANCELLED │◀────│ DELETE │
│ (slot free) │ │ (cancel) │
└─────────────────┘ └─────────────────┘

Source: libs/tee-time-services/src/lib/facade/booking.controller.ts

Slot States

StateDescriptionBooking Status
AVAILABLEOpen for bookingNone
HELDTemporarily reservedPENDING
BOOKEDPermanently reservedCONFIRMED
BLOCKEDAdministratively blockedNone

Booking States

StatusDescriptionNext States
PENDINGHold created, awaiting confirmationCONFIRMED, CANCELLED
CONFIRMEDBooking finalized with paymentCANCELLED
CANCELLEDBooking cancelled, slot releasedTerminal

Payment Status

StatusTrigger
UNPAIDDefault on hold creation
AUTHORIZEDPayment authorized but not captured
CAPTUREDPayment captured on confirm
REFUNDEDRefund processed after cancellation

Hold Creation

Endpoint

POST /bookings/hold

// Request
{
"tenantId": "tenant-123",
"slotId": "slot-456",
"bookingChannel": "online", // Optional: online | phone | walkIn
"contactName": "John Smith",
"contactEmail": "john@example.com",
"contactPhone": "+1234567890",
"externalRef": "ext-ref-789", // Idempotency key
"additionalItems": [
{ "additionalItemId": 123, "quantity": 1 }
]
}

// Response
{
"id": "booking-abc",
"status": "PENDING",
"slotId": "slot-456",
"contactName": "John Smith"
}

// Headers
X-Booking-Status: PENDING
X-Idempotent: false
X-Booking-Channel: phone | walkIn | online // Optional override (header wins over body)

Hold Flow

  1. Tenant Isolation — Verify user has access to tenant
  2. Idempotency Check — Return existing booking if externalRef matches
  3. Slot Validation — Verify slot exists and belongs to tenant
  4. Advance Window — Enforce advanceWindowDays policy
  5. Channel enforcement — Apply course bookingChannels toggles; phone/walkIn are staff-only
  6. Additional Items — Validate and create item reservations
  7. Create Booking — Status: PENDING, linked to slot
  8. Publish Eventslot.held gateway event

Idempotency

Three-layer protection:

// Layer 1: Database unique constraint
@@unique([tenantId, externalRef])

// Layer 2: Hard lookup before validations
if (externalRef) {
const existing = await findByTenantAndExternalRef(tenantId, externalRef);
if (existing) return existing; // Return with X-Idempotent: true
}

// Layer 3: Redis distributed lock
const key = `idemp:tt:booking:${tenantId}:${externalRef}`;
const acquired = await cache.setIfAbsent(key, ttlSeconds);
if (!acquired) {
// Lock busy - check for concurrent booking
const concurrent = await findByTenantAndExternalRef(tenantId, externalRef);
if (concurrent) return concurrent;
throw new Error('Idempotency lock busy; retry');
}

Configuration:

BOOKING_IDEMPOTENCY_TTL_SECONDS=60  # Default lock TTL

Confirmation

Endpoint

POST /bookings/:id/confirm

// Request
{
"amountCents": 5000, // Payment amount
"paymentMethod": "card", // Optional
"benefitMembershipNumber": "M-001", // For discounts
"benefitProviderCode": "GOLFRSA" // Benefit provider
}

// Response
{
"id": "booking-abc",
"status": "CONFIRMED",
"paymentStatus": "CAPTURED",
"paidAt": "2025-12-15T08:00:00Z"
}

// Headers
X-Booking-Status: CONFIRMED
X-Idempotent: false

Confirmation Flow

  1. Status Check — Must be PENDING (or CONFIRMED for idempotent retry)
  2. Eligibility Check — Validate benefit membership if provided
  3. Update Booking — Status: CONFIRMED, payment captured
  4. Facilities Release — Release cart assignment if applicable
  5. Sync Items — Confirm item reservations, cancel unselected
  6. Publish Eventsteetime.booking.confirmed, slot.booked
  7. Record Benefit — Log benefit application for audit

Idempotent Confirmation

Re-confirming an already confirmed booking:

  • Updates payment fields if amountCents provided
  • Returns existing booking with X-Idempotent: true
  • Safe to retry without side effects

Cancellation

Endpoint

DELETE /bookings/:id?playerType=member

// Response
{
"id": "booking-abc",
"status": "CANCELLED"
}

Cancellation Flow

  1. Booking Validation — Verify exists and tenant access
  2. Policy Check — Enforce cancellation window
  3. Cancel Booking — Status: CANCELLED
  4. Release Facilities — Return cart assignment
  5. Cancel Items — Update item reservations to CANCELLED
  6. Waitlist Processing — Notify waitlisted players
  7. Publish Eventsteetime.booking.cancelled, slot.released

Cancellation Policy

interface CancellationOptions {
minHoursBefore: number; // Hours before tee time
allowReschedule?: boolean; // Allow within window
perType?: Record<string, { // Per-player-type overrides
minHoursBefore: number;
allowReschedule?: boolean;
}>;
}

Example Configuration:

{
minHoursBefore: 24, // Default: 24h notice
allowReschedule: true,
perType: {
'member': { minHoursBefore: 4, allowReschedule: true },
'visitor': { minHoursBefore: 48, allowReschedule: false }
}
}

Policy Errors:

// Within cancellation window
throw new BadRequestException('Cancellation not allowed within cancellation window');

// Slot unavailable (for reschedule)
throw new BadRequestException('Target slot is no longer available');

Rescheduling

Endpoint

PATCH /bookings/:id/reschedule

// Request
{
"newSlotId": "slot-789",
"playerType": "member", // Policy override
"notifyPlayers": true, // Send notifications
"moveCartAssignment": true // Move cart to new slot
}

// Response
{
"id": "booking-abc",
"status": "CONFIRMED",
"slotId": "slot-789"
}

Reschedule Rules

  • Must be same club (prevents provider desync)
  • Subject to cancellation policy unless allowReschedule: true
  • Moves cart assignment if configured
  • Publishes teetime.booking.rescheduled event

Additional Items

Items (carts, caddies, etc.) can be added during hold:

// In HoldBookingDto
additionalItems: [
{ additionalItemId: "cart-electric", quantity: 1 },
{ additionalItemId: "caddie", quantity: 1 }
]

Item Lifecycle:

  • Created with PENDING status on hold
  • Updated to CONFIRMED on booking confirm
  • Updated to CANCELLED on booking cancel
  • Unselected items cancelled during confirm

Events

Domain Events (Outbox)

EventTrigger
teetime.booking.confirmedBooking confirmed
teetime.booking.cancelledBooking cancelled
teetime.booking.rescheduledBooking moved to new slot
teetime.booking.reminderScheduled reminder (24h before)

Gateway Events (WebSocket)

EventTrigger
slot.heldHold created
slot.bookedBooking confirmed
slot.releasedBooking cancelled
booking.updatedAny status change
booking.cancelledCancellation

Tenant Isolation

Three-layer protection:

  1. JWT Scope — User must have tenantId in claims
  2. Parameter Validation — Request tenant matches user's tenant list
  3. Resource Check — Slot/booking belongs to same tenant
// Throws ForbiddenException if mismatch
assertTenantAccess(user, tenantId);
assertSlotTenant(slot, tenantId, 'hold');

Response Headers

HeaderValuesDescription
X-Booking-StatusPENDING, CONFIRMEDCurrent booking status
X-Idempotenttrue, falseWhether request was idempotent hit
X-Idempotency-Key<key>Echo of provided key

Error Handling

ErrorStatusCause
SlotUnavailableError400Slot already claimed
CancellationWindowError400Within cancellation window
ForbiddenException403Tenant access denied
NotFoundException404Booking/slot not found
ConflictException409Duplicate externalRef

Metrics

MetricDescription
booking_hold_latencyHold creation time
booking_confirm_latencyConfirmation time
booking_errorsErrors by type and operation
booking_idempotent_hitsIdempotent request count