Skip to main content
Synced from the repo — do not edit here

Canonical source: docs/claude/client-booking-access-payment-contract.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.

Client Booking — Access & Payment Contract (all client surfaces)

Canonical rules for WHO can see/book a service and WHEN a booking requires payment. Extracted 2026-08-05 from the reference implementation (basishybrid lib/view/routes/schedule/schedule.dart — the redesign wraps this same widget) so basisweb /portal/book and any future surface implement the SAME contract. If you change a rule here, change it everywhere and update this doc.

⚠️ The backend does NOT yet enforce isPublic or benefit checks on book_appointment (#672). Until that lands, every client surface MUST implement the full client-side gate below — a surface that skips it lets clients book staff-internal / members-only services.

1. Service visibility (which services a client may see at all)

Per service flag: isPublic ("Available to non-members" in Flow settings). Reference: _isServiceAccessible (schedule.dart ~186).

Client stateSees
No membershipPUBLIC services ONLY (George's rule 2026-08-05 — no allow-all fallback; mobile commit d1830d8ab)
Has membershipPublic services plus non-public services with a matching membership.benefits[].serviceId

Re-check the same rule at the slot screen (schedule.dart ~2790) — never rely on list filtering alone. Staff-internal blocking services are isPublic=false by design and must never surface.

2. Location + policy gates (after visibility)

  • Location access: if the membership defines accessibleLocationIds (non-empty), slots at other locations are blocked.
  • Join policy resolution order: service-specific policy in membership.joinPolicies → generic policy → membership.defaultJoinPolicy (_effectiveJoinPolicy, schedule.dart ~1631).
  • Enforce the policy's booking time windows (min/max notice) and the release-mode horizon on the slot grid (~1669-1720, ~2877).
  • Group services: capacity/full checks, seats-left, already-booked guard, waitlist join when full (hasWaitlist).

3. Payment — when a booking costs money

Reference: _checkPaymentRequirement (~3698) + _hasFreeBenefitForService (~4320).

if service.stripePrice == null OR unit_amount <= 0:
booking is FREE (no payment infra for this service)
else:
free_access = resolve below
requiresPayment = !free_access # fail CLOSED: resolution error ⇒ pay

Free-access resolution (ordered — first hit wins)

  1. Membership benefit allowance — benefit for THIS service with freeBookings != 0:
    • freeBookings == -1 → unlimited → FREE.
    • else count the client's bookings of this service in the CURRENT period and FREE while usage < freeBookings.
    • Period math (_getUserServiceUsageCount ~4220): calendar month/year per freeBookingsInterval, computed in the clinic location's timezone, anchored at the slot's start date (not "today" — matters for bookings into next month). Usage counting EXCLUDES the three cancel statuses (canceled, early_canceled, late_canceled).
  2. Trial / credits / package (via the get_membership_usage API) — ONLY when the service is in the membership's benefits at all:
    • trial: !trialExpired && trialBookingsRemaining > 0
    • credits: creditsRemaining > 0
    • package: !packageExpired && sessionsRemaining > 0
  3. Otherwise → payment required at stripePrice.unit_amount / currency.

Over-capacity behavior (the key product rule)

Exceeding the monthly/period allowance does NOT block the booking — it converts it to a paid booking at the service price. Show the client why ("You've used your N included sessions this month — this one is $X"), collect payment (Stripe PaymentSheet / Payment Element) before calling book_appointment, then book. If the service has no stripePrice, an exhausted allowance books free (nothing to charge — mirror mobile).

4. Booking-time validation (immediately before book_appointment)

  • _validateMembershipRestrictions (~4033): join-policy compliance re-check.
  • Cross-booking limit (~4830): bookings across services counted against the membership's cross-service cap.
  • Conflict/blocking-appointment detection (offer reschedule of the blocker).
  • These are client-side too — the backend re-validates only reschedule rules.
  • clientAppReschedule.{enabled,cutoffMinutesBefore,sameDayOnly} — reschedule availability + cutoff; off ⇒ contact-the-clinic message.
  • Cancellation: lateCancellation flag must be COMPUTED from the membership CancellationPolicy.checkLateCancellation (backend trusts it for early-vs-late status + fee); show the real lateCancellationFee, and post-cancel fee copy only on event.feeCharged == true (never inferred).

Reference implementations

  • Mobile (canonical): hybrid/basishybrid/lib/view/routes/schedule/schedule.dart
  • Fee/status models: hybrid/basiscore/lib/src/models/clinic.dart (MembershipType, Benefit.freeBookings, canClientReschedule, CancellationPolicy), event/constants/event_status.dart (isCanceled).
  • Backend request types: book_appointment, cancel_appointment, confirm_appointment, get_membership_usage, get_available_service_times (see REQUEST_MODEL_MAP, functions_clinic.py).