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/bookand 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
isPublicor benefit checks onbook_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 state | Sees |
|---|---|
| No membership | PUBLIC services ONLY (George's rule 2026-08-05 — no allow-all fallback; mobile commit d1830d8ab) |
| Has membership | Public 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)
- 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 perfreeBookingsInterval, 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).
- Trial / credits / package (via the
get_membership_usageAPI) — ONLY when the service is in the membership's benefits at all:- trial:
!trialExpired && trialBookingsRemaining > 0 - credits:
creditsRemaining > 0 - package:
!packageExpired && sessionsRemaining > 0
- trial:
- 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.
5. Related toggles (same clinic-doc family)
clientAppReschedule.{enabled,cutoffMinutesBefore,sameDayOnly}— reschedule availability + cutoff; off ⇒ contact-the-clinic message.- Cancellation:
lateCancellationflag must be COMPUTED from the membershipCancellationPolicy.checkLateCancellation(backend trusts it for early-vs-late status + fee); show the reallateCancellationFee, and post-cancel fee copy only onevent.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(seeREQUEST_MODEL_MAP, functions_clinic.py).