ADR: Pass entitlement — per-extra coverage with quantity
ADR: Pass entitlement — per-extra coverage with quantity
Section titled “ADR: Pass entitlement — per-extra coverage with quantity”Status
Section titled “Status”PROPOSED
AC ↔ D# mapping (N3)
Section titled “AC ↔ D# mapping (N3)”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1, D2 (template shape), D3, D10 | Storage join table + business response shape + per-booking quantity semantics. |
| AC-2 | D4, D5 (ownership/activity/status guards), D6, D7 | Split logic, defensive entitlement validation, row split, and atomic transaction. |
| AC-3 | D2 (client shape), D4, D8 | Per-extra badge + payment-method picker visibility tied to allowed ExtrasPaymentMethod values. |
| AC-4 | D2 (client shape isActive), D9, D11 | Pass-detail returns soft-deleted extras with isActive: false. Snapshot-vs-current semantics pinned in D11 (B3). |
| AC-5 | D9 | Soft-delete cascade — exhaustive read/write enumeration. |
| AC-6 | All | Contract patches for both surfaces. |
| AC-7 | D5 | Explicit pick + server-side defensive validation of customerEntitlementId. |
| AC-8 | D6 | covered_by_entitlement_id + price_paid keep cancellation flow unchanged but reportable. |
Context
Section titled “Context”Today the booking flow with paymentMethod=PASS (file
tktspace-backend/libs/features/activities/src/lib/services/bookings-client.service.ts,
method createBooking lines 60–162) computes
totalPrice = sessionPrice + extrasTotal (line 75) and then forwards that sum
to bookWithPass (lines 91–98 → 693–717). The pass entitlement only consumes
ONE session counter; nothing on the entitlement template knows whether an
extra is covered or not. Consequence: extras attached to a pass booking are
recorded with their snapshot price BUT no money ever leaves the customer’s
wallet — the bookWithPass insert does not debit anything; it just sets
walletDebited = true to pacify the existing flag invariant. Studio owners
silently lose revenue on every pass booking that includes extras.
The spec (AC-1..AC-8) closes the gap by:
- letting admins enumerate which extras of each covered activity are included in a pass entitlement template, and HOW MANY units per booking (quantity);
- splitting the requested extras at booking time into covered units (price 0)
and not-covered units (charged via a NEW request field
extrasPaymentMethod, which mirrors the existing booking payment-method enum minusPASS); - showing per-extra coverage badges on the mobile checkout (gym_app);
- listing covered extras grouped by activity on the pass detail screen, including soft-deleted extras with a “(removed)” suffix;
- introducing a soft-delete (
is_active = false) flag on extras so deletion doesn’t blow up referential integrity in pass templates. Theactivities.activity_extras.is_activecolumn ALREADY EXISTS in the schema (tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.tsline 302); see “Data model changes” below — no schema work is needed for AC-5.
Two surfaces are affected: business (admin pass-template editor) and client (mobile gym_app checkout + pass detail). No super-admin work. No web / landing / tickets_app changes. No model change (per-session consumption preserved). No new cancellation flow. No multi-currency.
Decision
Section titled “Decision”D1 — Storage shape: a dedicated join table
Section titled “D1 — Storage shape: a dedicated join table”Per the spec and the user’s confirmed direction, persist coverage as a join table:
passes.pass_entitlement_covered_extras id uuid (pk) entitlement_template_id uuid (fk -> passes.pass_entitlement_template.id, on delete restrict) extra_id uuid (fk -> activities.activity_extras.id, on delete restrict) quantity integer not null check (quantity >= 1) created_at timestamp default now() not null updated_at timestamp default now() not null unique (entitlement_template_id, extra_id)Rationale recap (full discussion in “Considered alternatives”): a join table keeps coverage data normalized, queryable in a single grouped SQL, constraint-checkable (FK guarantees the extra exists; the unique pair guarantees we never store two rows for the same extra under one entitlement, which would let an admin double-count quantity), and indexable for the “render covered extras for this customer-pass” query the mobile pass detail needs. JSONB was rejected; see Alt 1 below.
D2 — Response shape on both surfaces: coveredExtras: [{extraId, quantity}]
Section titled “D2 — Response shape on both surfaces: coveredExtras: [{extraId, quantity}]”A short, flat array per entitlement, keyed by extraId. The mobile UI joins
this array against the activity-extras catalogue it already loads for the
checkout (so it can render name + price); the business UI joins against
the activity-extras admin list it already loads for the entitlement editor.
The pass-detail mobile screen needs slightly more (name, price,
isActive) so the list renders without a second round-trip; for that
screen the backend RETURNS extras inline as
coveredExtras: [{extraId, name, price, quantity, isActive}] (denormalized
on read). For the business pass-template list/get, the response form is
[{extraId, quantity}] only — the admin client already has the activities’
extras list loaded. Two SHAPES, same field name on the wire is intentionally
not done; the two endpoints declare distinct schemas
(CoveredExtraTemplateDto for business, CoveredExtraClientDto for client)
to avoid silent contract drift.
D3 — Request shape: coveredExtras in create/update pass DTOs
Section titled “D3 — Request shape: coveredExtras in create/update pass DTOs”Extend CreatePassEntitlementDto and UpdatePassEntitlementDto (business
contract) with optional coveredExtras: [{extraId, quantity}]. Empty/omitted
array means “no extras covered” (default behavior — not a breaking change for
existing pass templates). Server validates each extraId exists, belongs to
the entitlement’s activityId (cross-entity check), and quantity >= 1.
For UPDATE the reconciliation rule mirrors the existing entitlements /
prices semantics on UpdatePassDto: coveredExtras is replaced in full
when supplied (no per-row id reconciliation — the natural key is the
(entitlement_template_id, extra_id) pair, so server diffs by extra_id).
This keeps server logic simple and matches the “replace in full on save”
promise already documented in UpdatePassDto.entitlements.
D4 — extrasPaymentMethod on the booking request
Section titled “D4 — extrasPaymentMethod on the booking request”Add an optional extrasPaymentMethod field to CreateClientBookingDto
(client contract). Enum is the existing BookingPaymentMethod values minus
PASS (covering with another pass is a future feature, not in scope).
Validation rule (server-side, AC-2 explicit):
- If
paymentMethod === PASSAND the resolved split has ZERO not-covered units →extrasPaymentMethodMUST be omitted; if present, server returns HTTP 400 with codeerrors.booking.extras_payment_method_unexpected(avoid charging a customer who has no charge to apply to). - If
paymentMethod === PASSAND the resolved split has at least one not-covered unit →extrasPaymentMethodis REQUIRED; if absent, server returns HTTP 422 with codeerrors.booking.extras_payment_method_requiredper the spec. - If
paymentMethod !== PASS→extrasPaymentMethodis ignored / rejected (the existingpaymentMethodalready governs charging the entire amount).
The 422 vs 400 distinction is intentional: a missing extrasPaymentMethod
under coverage is a resource-state error (the request shape is
well-formed; the customer cannot supply a value they don’t have without
re-rendering the picker the mobile already hides), which is exactly what
422 signals. A present extrasPaymentMethod when none is allowed is a
request shape error (the mobile shouldn’t have collected the value),
which is exactly what 400 signals. Both cases align with the broader
400-vs-422 convention articulated in D9.
D5 — Backend resolves which entitlement was used: NEVER auto-resolves; ALWAYS defensively validates
Section titled “D5 — Backend resolves which entitlement was used: NEVER auto-resolves; ALWAYS defensively validates”AC-7 mandates explicit pick when more than one entitlement matches. The
backend already supports customerEntitlementId on CreateClientBookingDto
(present today; the existing validateAndUseEntitlement consumes it). The
mobile sends it exactly as supplied. The backend:
- still requires
customerEntitlementIdforpaymentMethod=PASS(existing behavior, unchanged); - never picks one of multiple matching entitlements server-side, even when extras coverage differs across them. The mobile is responsible for surfacing the pick to the user;
- never trusts the supplied
customerEntitlementIdblindly. Before consuming it (decrementing the session counter, computing the covered/not-covered split, charging the not-covered units, or inserting anybooking_extrasrow),bookings-client.service.tsbookWithPassAndExtras(and the pre-existingbookWithPassfor the no-extras path) MUST run the defensive guards below. A stale or tampered id is an IDOR / wrong-customer-charging risk; defending is non-negotiable.
Defensive validation guards (server-side, MUST run inside the booking transaction before any state mutation):
i18n key form (C1): every error code in this ADR — including the guard table below, D9, the AC-7 cross-reference, and any contract description block — uses the FULL
errors.<surface>.<code>form (e.g.errors.pass.entitlement_not_owned,errors.extras.no_longer_available,errors.booking.extras_payment_method_required). This is whatnestjs-i18nactually keys against — the bare<surface>.<code>form would silently fall through todefaultValueat runtime and ship un-localized copy to the client. Backend-dev MUST mirror the exact key form intoapps/api/src/i18n/{en,uk}/errors.json.
| Guard | Check | Failure HTTP | Error code (nestjs-i18n key) |
|---|---|---|---|
| Ownership | The entitlement’s parent customer_pass.customer_id equals the authenticated user’s customer id for the company. | 403 Forbidden | errors.pass.entitlement_not_owned |
| Activity match | The entitlement’s template activity_id equals the booking’s activity_id. | 422 Unprocessable Entity | errors.pass.entitlement_activity_mismatch |
| Status usable | The entitlement’s parent customer_pass.status is in {ACTIVE, PAUSED}. EXPIRED / CANCELLED / AWAITING_PAYMENT / PENDING are rejected. | 422 | errors.pass.entitlement_unusable |
| Sessions remaining | If template.sessions_limit IS NOT NULL then customer_entitlement.sessions_used < template.sessions_limit. Unlimited entitlements (sessions_limit IS NULL) skip this check. | 422 | errors.pass.entitlement_exhausted |
These are atomic preconditions — the transaction aborts and rolls back
on any single failure; no partial state is persisted. The localized
message bundle gets matching uk and en entries (the mobile already
has the localized-message extraction pipeline from the implement-passes
effort). On failure the response uses the existing ErrorResponse
schema; the code field carries one of the values above.
Cross-reference: AC-2’s split computation (D6) MUST operate on the already-validated entitlement. Computing the split first and then validating would let a tampered id leak coverage info from another customer’s entitlement into the response. Order is: validate guards → compute split → mutate (counter, booking, booking_extras, wallet/bonus debit).
The “which entitlement was used” question for booking-extra rows is
deterministic: the booking row carries customer_entitlement_id; every
covered booking-extra row inherits the same value (denormalized into a new
booking_extras.covered_by_entitlement_id column — see D6) so reporting
and refund queries can answer “which entitlement absorbed this extra”
without re-resolving at read time.
D6 — Booking-extra row gains covered_by_entitlement_id and price_paid
Section titled “D6 — Booking-extra row gains covered_by_entitlement_id and price_paid”Today booking_extras (tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts
lines 65–80) stores a single row per (bookingId, extraId) pair with the
total quantity. Add two columns:
price_paid decimal(10,2) not null default '0'— the per-unit price actually charged for this row. For covered units this is'0.00'. For not-covered units this is the snapshot ofactivity_extras.priceat booking time (same value the existingpricecolumn already stores — see “Why bothpriceandprice_paid” below).covered_by_entitlement_id uuid null references passes.customer_entitlement(id) on delete set null— set to the resolvedcustomerEntitlementIdfor covered rows; null for not-covered rows. The on-delete-set-null is defensive (entitlements are not hard-deleted today, but if that ever changes this avoids dangling refs).
To represent the split — covered units at price 0 vs not-covered units at
price > 0 within ONE booking — the booking-create flow now inserts up to
TWO booking_extras rows per requested extra:
- one row with
quantity = min(requested, coveredQty),price = '0.00',price_paid = '0.00',covered_by_entitlement_id = entitlementId(only when at least one unit is covered); - one row with
quantity = max(0, requested − coveredQty),price = activity_extras.pricesnapshot,price_paid = same,covered_by_entitlement_id = null(only when at least one unit is not covered).
Both rows are emitted only for extras whose split has both halves; for single-half cases (all covered OR all not-covered) only one row is emitted.
Deterministic insert order (N2): when both halves exist for the same
extraId, the booking-create flow inserts the covered row first
(lower auto-increment booking_extras.id / earlier created_at), then
the not-covered row. Reads that need a stable shape — booking-detail
responses, reports, refund flows — use:
ORDER BY extra_id ASC, covered_by_entitlement_id NULLS LASTNULLS LAST keeps the covered row (non-null covered_by_entitlement_id)
ahead of the not-covered row (null) for the same extra_id. Reports get
a stable, predictable per-extra shape regardless of insert race timing.
Why both price and price_paid? The existing price column is a
unit-price snapshot (per the existing comment “Snapshots at booking time”).
We KEEP it as the snapshot of the catalog price at the time of booking
(useful for “what did this extra cost back then” reports independent of
coverage). price_paid is the per-unit AMOUNT actually billed — for covered
units it’s 0 even when price (the catalog snapshot) is 1500 UAH. Reporting
queries that need “total revenue from extras” sum
price_paid * quantity; queries that need “what did the customer SEE”
sum price * quantity. The two columns answer different questions; both
are cheap.
D7 — Booking-create transaction boundary
Section titled “D7 — Booking-create transaction boundary”The existing createBooking flow inserts booking_extras AFTER the per-payment
branch returns (lines 105–115 in bookings-client.service.ts). For pass
bookings with mixed coverage we need:
- the booking row, the entitlement’s session-counter increment, the wallet
debit (if
extrasPaymentMethod=WALLET), and ALLbooking_extrasrows to commit atomically (or none of them — same as today for pure WALLET / pure PASS bookings).
The cleanest way to keep this simple is to wrap the new pass-with-extras
branch in a single Drizzle db.transaction(...). The existing
bookWithWallet is also already transactional (it uses
walletService.debit which itself runs in a tx). The new method
bookWithPassAndExtras (called when paymentMethod=PASS AND extras is
non-empty) replaces both the existing call to bookWithPass and the
unconditional insert(bookingExtras) block. For paymentMethod=PASS with no
extras, the existing flow stays as-is.
The session-counter decrement happens exactly ONCE inside the transaction
via the existing validateAndUseEntitlement (which already updates
customer_entitlement.sessionsUsed). Per AC-2 explicit: regardless of how
many covered/not-covered rows are inserted, the session counter is bumped
exactly once.
For extrasPaymentMethod=LIQPAY see D8 — that path is intentionally
rejected at the contract level for V1.
D8 — Allowed extrasPaymentMethod values for V1
Section titled “D8 — Allowed extrasPaymentMethod values for V1”The spec says extrasPaymentMethod is “the existing payment-method enum
minus PASS”. The existing BookingPaymentMethod is
[ON_SITE, WALLET, LIQPAY, DEFER, PASS, BONUS]. For V1, the
contract-level allowed set for extrasPaymentMethod is:
[ON_SITE, WALLET, BONUS]
LIQPAY is excluded for V1 because the existing booking-LIQPAY flow creates a
separate payment intent and waits for a webhook to flip the booking to
CONFIRMED. Adding “covered + LIQPAY-paid extras” introduces a half-paid
booking state machine (booking is CONFIRMED via PASS but extras are still
AWAITING_PAYMENT) that the existing schema does NOT model and that would
demand a booking_extras.status column plus webhook reconciliation. That’s a
materially larger change and is OUT OF SCOPE per the spec’s “stop scope”
direction (this ADR scopes that out explicitly in “Stop scope” below).
DEFER is excluded because the existing bookDeferred path also doesn’t
debit anything immediately, so combining it with PASS yields no observable
behavior difference vs the current bug — the goal is to charge the not-covered
units now. If a future ticket wants “pay extras later”, that’s a separate
design.
This narrowing is encoded in the contract via a dedicated enum
ExtrasPaymentMethod (NOT a not operator on BookingPaymentMethod) so
generated clients get a strongly-typed enum.
D9 — Soft-delete cascade behavior
Section titled “D9 — Soft-delete cascade behavior”activity_extras.is_active already exists. The change is in the QUERIES
that already exist. Backend-dev MUST patch every path in the table below;
this is the exhaustive enumeration — no “verify and patch” hedging.
| Path | Direction | Filter | Reason |
|---|---|---|---|
GET /api/client/activities/:id extras section (mobile catalogue) | read | WHERE activity_extras.is_active = true | Customers cannot see / select removed extras when starting a booking. |
Mobile booking-create extras lookup before charge (resolveExtras inside bookings-client.service.ts) | read | WHERE activity_extras.is_active = true | Defence — even if mobile sends a stale id, backend rejects. Returns HTTP 422 with code errors.extras.no_longer_available (N5). |
| Business panel pass-form editor extras list (chip-picker fed by activity-extras admin endpoint) | read | WHERE activity_extras.is_active = true | Admin cannot select removed extras into NEW or EDITED templates. |
Pass detail / pass list — GET /api/client/companies/:cid/passes/mine and GET /api/client/companies/:cid/passes/activities/:aid/my-entitlements (and the pass catalogue GET /api/client/companies/:cid/passes) | read | NO FILTER on is_active (returns inactive too) | AC-4 dim-and-label — soft-deleted extras stay listed because the entitlement-template still references them. Each CoveredExtraClientDto carries isActive so mobile renders “(removed)”. |
Business POST/PUT pass-template coveredExtras[] validator | write | Reject any extraId whose activity_extras.is_active = false → HTTP 400 with code errors.extras.cannot_cover_inactive | Admin cannot reference a removed extra in a new or edited template (preserving existing template references is the soft-delete invariant; introducing a new reference to an already-removed extra is malformed input). |
Mobile booking-create extras processing (per-item resolve in bookWithPassAndExtras and the existing wallet/on-site/bonus paths) | write | Each extraId MUST resolve to a row with is_active = true; otherwise HTTP 422 with code errors.extras.no_longer_available (N5) | Defence at the consume boundary; matches the catalogue filter so a replay of an old payload after admin soft-delete is rejected with the right localized copy. |
400 vs 422 rationale (C2). Two soft-delete failure paths in the table
above split on the HTTP status code: errors.extras.cannot_cover_inactive
returns 400 on the business pass-template create/update; both
errors.extras.no_longer_available paths return 422 on customer
booking-create. Both cases are conceptually “request well-formed,
resource state forbids it” — the split is deliberate and reflects the
DIFFERENT mental models of the two callers:
- 400 for the admin pass-template form (
cannot_cover_inactive): the admin can fix this on the spot — they re-open the form, deselect the inactive extra, and resubmit. 400 communicates “your input is invalid” which is the right mental model for a form error in an editor where the operator has agency to correct the input. - 422 for the customer booking-create (
no_longer_available): the customer cannot fix the input at all — the extra was removed by the studio admin between catalogue load and booking submit, so the input was VALID at compose time and only became invalid because of a server-side state change. 422 (“Unprocessable Entity”) communicates “your request is well-formed but the server cannot fulfill it because state changed”, which is the right mental model for a catalogue race the customer didn’t cause.
The same split is applied to D4’s extrasPaymentMethod validation: 422
when the field is missing under coverage (the customer cannot provide
information they don’t have without re-rendering the picker), 400 when
the field is present but forbidden (the customer’s request is malformed
because the picker shouldn’t have been shown).
Notes:
- The mobile already has the localized-message extraction pipeline; the
i18n bundle gets matching keys for both
errors.extras.no_longer_availableanderrors.extras.cannot_cover_inactive(uk + en). - The pass-detail / catalogue read paths intentionally bypass the
is_activefilter so the customer sees the historical reference; theisActiveboolean on eachCoveredExtraClientDtocarries the truth to the UI. The contract response examples for those paths must showisActive: falseis possible (see Surface impact section). The catalogue and booking-create paths NEVER include inactive extras — this divergence is the whole point of the soft-delete model.
D10 — Quantity is per-booking (matches per-session model)
Section titled “D10 — Quantity is per-booking (matches per-session model)”Per AC-1: quantity is the cap PER BOOKING. Each booking is one session
consumed, so the cap resets each booking. No state is needed across bookings;
the customer_entitlement row does NOT track per-extra usage. This keeps
the model orthogonal to the existing per-session counter.
If a customer books the same activity 5 times in a week with a pass that
covers mat: 1, they get 5 free mats total (1 per booking) — which is the
intuitive read of the spec language and matches the existing per-session
semantics.
D11 — Coverage is CURRENT, not snapshotted; “(removed)” applies only to soft-deleted extra rows (B3)
Section titled “D11 — Coverage is CURRENT, not snapshotted; “(removed)” applies only to soft-deleted extra rows (B3)”AC-4 says the customer “sees what their pass originally included” with “(removed)” / “(вилучено)” labels for soft-deleted extras. That phrasing mixes two distinct cases that this ADR pins explicitly so the contract and the implementation match:
-
Soft-deleted extra row (
activity_extras.is_active = false): the extra row itself is removed by an admin via the soft-delete flow. The entitlement-template still references it viapass_entitlement_covered_extras(the FK isON DELETE RESTRICT, and the soft-delete does not delete the row). The pass-detail / catalogue responses keep returning the entry, withisActive: false. The mobile UI dims the row and appends a localized “(removed)” / “(вилучено)” suffix. This is the only case where “(removed)” appears. -
Admin edits the template to drop coverage (admin removes the extra from
coveredExtras[]on the entitlement via PUT): the row disappears frompass_entitlement_covered_extrasentirely. Pass-detail and catalogue responses no longer return the entry — there is NO “(removed)” label. The pass policy genuinely changed; this is fine and intentional. The customer sees the new coverage on their next list fetch (passes-detail is not snapshotted at purchase — see below).
Coverage is NOT snapshotted at purchase. The data model stores
coverage on the template (pass_entitlement_covered_extras keyed by
entitlement_template_id). Customer passes pick up template edits on
their next read. Storing snapshots (e.g. a customer_pass_covered_extras
copy frozen at purchase) is out of scope — it would require:
- doubling the storage shape (template + per-customer snapshot);
- an admin UX to re-confirm whether existing customers should be migrated on every coverage edit;
- a refund-policy decision when an admin shrinks coverage post-purchase.
These are non-trivial product calls. The spec’s coveredExtras description
on MyEntitlementDto already hints “current template-level coverage (not
snapshotted at purchase)” — D11 makes the rule explicit and gives the
two cases distinct UI treatment.
The spec AC-4 wording is updated alongside this ADR (B3) so both
documents agree on the semantic. The contract description on
MyEntitlementDto.coveredExtras is also tightened to mention that
admin template-edit removals are NOT labelled “(removed)” — only
soft-deleted underlying extra rows are.
Considered alternatives
Section titled “Considered alternatives”Alt 1 — JSONB column on pass_entitlement_template (e.g. covered_extras: jsonb)
Section titled “Alt 1 — JSONB column on pass_entitlement_template (e.g. covered_extras: jsonb)”Trade-off: zero migration churn for the join layer (one new column vs one new table). Single round-trip read. But:
- Loses FK to
activity_extras.id— orphaned references when an extra is hard-deleted (the spec mandates soft-delete to dodge this; FK is the belt-and-braces guarantee). - Loses the unique constraint on
(entitlement, extra)— the admin UI could push two rows for the same extra and silently corrupt the cap. - Loses queryability — “which pass templates cover extra X?” is a JSONB scan vs an indexed join.
- Drizzle’s typed access to nested JSONB is awkward; the codebase favors
normalized tables (
pass_price,pass_entitlement_templateare both separate tables for the same reasons).
Rejected. The user explicitly confirmed the join-table approach.
Alt 2 — Auto-resolution of multiple matching entitlements (server picks the best one)
Section titled “Alt 2 — Auto-resolution of multiple matching entitlements (server picks the best one)”Trade-off: avoids the explicit-pick UI step. But:
- “Best” is undefined when the entitlements have different coverage:
pick the one with the most extras covered? Pick the one with the
earliest
validUntil? Pick the cheapest? Every choice is a policy the customer didn’t see; surprise charging is the worst UX. - AC-7 explicitly mandates explicit pick. The rationale is in the spec: “explicit pick is the cleanest UX guarantee with no surprise charging.”
- Backend stays simpler — no scoring function, no precedence rules to test, no behavior change when a future entitlement gets added that outranks the previous “best”.
Rejected. The mobile sends customerEntitlementId exactly; backend trusts it.
Alt 3 — Make extrasPaymentMethod reuse paymentMethod’s enum literal
Section titled “Alt 3 — Make extrasPaymentMethod reuse paymentMethod’s enum literal”Trade-off: one fewer enum on the wire. But:
paymentMethod=PASSis a valid value of the parent enum, and a customer cannot legally passextrasPaymentMethod=PASS(would mean “use a pass to pay for extras”, which is the original bug). Encoding “the parent enum minus one value” via JSON Schemanotis supported but not all client generators (especially Dartchopper_generator) handle it cleanly.- Distinct enums document intent — readers see immediately that the field takes a narrower set.
Rejected. We declare a separate ExtrasPaymentMethod enum.
Alt 4 — Per-unit booking_extras rows (one row per single unit, no quantity column)
Section titled “Alt 4 — Per-unit booking_extras rows (one row per single unit, no quantity column)”Trade-off: would let the split express any combination via row count alone. But:
- 4× row count for the typical “towel × 4” booking — pure waste.
- Forces the existing
booking_extrasAPI to fan out — unrelated reports break. quantityalready exists on the table; addingprice_paidnext to it is the same shape we use today, just with one extra column.
Rejected. Keep the quantity column; emit at most TWO rows per requested
extra (one covered, one not-covered).
Surface impact and field-visibility justification
Section titled “Surface impact and field-visibility justification”Business surface (contracts/business.openapi.yaml)
Section titled “Business surface (contracts/business.openapi.yaml)”Touched schemas:
CreatePassEntitlementDto— adds optionalcoveredExtras: CoveredExtraTemplateDto[].UpdatePassEntitlementDto— adds optionalcoveredExtras: CoveredExtraTemplateDto[]with replace-in-full semantics.PassEntitlementTemplateResponseDto— addscoveredExtras: CoveredExtraTemplateDto[](always present, possibly empty).- New schema
CoveredExtraTemplateDto:{ extraId: string(uuid), quantity: integer minimum 1 }.
Why this minimal shape on business: the admin client already loads the activity’s extras list (name, price, isActive) for the entitlement editor — echoing those fields back in the pass response is duplication, and the admin needs them current (not snapshot) so they can re-pick if an extra was renamed. The admin endpoint returns ONLY the FK + quantity.
The business surface does NOT receive extrasPaymentMethod — that field is
client-side only (it controls how a CUSTOMER pays the not-covered units; the
admin issuing-pass flow doesn’t book; admin booking flow on behalf of a
customer is out of scope per the spec).
Client surface (contracts/client.openapi.yaml)
Section titled “Client surface (contracts/client.openapi.yaml)”Touched schemas:
CreateClientBookingDto— adds optionalextrasPaymentMethod: ExtrasPaymentMethodwith the validation rules in D4.- New schema
ExtrasPaymentMethod: enum[ON_SITE, WALLET, BONUS](D8).
Spec-divergence flag (N1): the spec language describes
extrasPaymentMethodas “the existing payment-method enum minus PASS” — i.e. five values[ON_SITE, WALLET, LIQPAY, DEFER, BONUS]. The ADR narrows the contract-level enum further to three values[ON_SITE, WALLET, BONUS], droppingLIQPAYandDEFER. Rationale is in D8 (LIQPAY introduces a half-paid booking state machine the existing schema does not model; DEFER yields no observable behavior difference vs the current bug). This is a deliberate scope-cap, not an omission — flagged here so spec reviewers see the divergence without tracing it from the contract.
MyEntitlementDto— addscoveredExtras: CoveredExtraClientDto[](always present, possibly empty) so the booking summary checkout can resolve coverage without a second call.MyEntitlementForActivityDto— addscoveredExtras: CoveredExtraClientDto[]. Used by themy-entitlementsendpoint that already feeds the entitlement picker; the picker shows “covers towels: 2” inline.PassEntitlementClientDto— addscoveredExtras: CoveredExtraClientDto[]so the pass catalogue (pre-purchase) can show what extras come with a pass before the customer buys it.- New schema
CoveredExtraClientDto:{ extraId: string(uuid), name: string, price: string, quantity: integer, isActive: boolean }. IncludesisActiveso the pass-detail render can dim and append “(removed)”; includesnameandpriceso the mobile UI doesn’t need to join against the activity-extras catalogue at render time.
Booking response shape (C3 — definitive). Audit of
contracts/client.openapi.yaml (May 2026): the booking endpoint paths
are NOT yet declared in the client contract (only passes endpoints
exist under paths:). However, the supporting schemas — including
CreateClientBookingDto, BookingExtraItemDto, and the per-extra
response schema BookingExtraResponseDto — ARE already declared in
components.schemas so consumers can regenerate typed clients now and
the mobile checkout can construct the new request shape. The existing
BookingExtraResponseDto schema (lines 595-636 of the contract) is
already extended with the two AC-2 fields:
pricePaid: string(decimal as string, required) — per-unit amount actually billed for the row;"0.00"for covered units, equalspricefor not-covered units.coveredByEntitlementId: string(uuid) | null(nullable) — set to the resolved customer-entitlement id for covered rows, null for not-covered rows.
These are the concrete schema names referenced by D6 (the row-split
shape) and D9 (the soft-delete read paths). When the booking endpoint
paths are added to the client contract in a future ticket, the
booking-create response object MUST reference BookingExtraResponseDto
under its extras: [] field — it is not a separate decision; this
schema IS the answer. AC-2 is therefore testable from the wire
contract today: a generated tktspace-mobile-app/packages/api Dart
client materializes the two new fields against a real
BookingExtraResponseDto from the response body. No “IF AND ONLY IF”
condition remains; the schema is committed.
Why field-visibility differs across surfaces:
- Business
CoveredExtraTemplateDtohas only{extraId, quantity}because the admin already has the activity’s extras list — sendingname,priceback would be stale-data. - Client
CoveredExtraClientDtohas{extraId, name, price, quantity, isActive}because the mobile checkout / pass-detail screens need to render names inline AND need theisActiveflag to dim soft-deleted entries — expecting the mobile client to chase a second request per row would balloon pass-detail latency. - Neither surface exposes the join-table row id or the
entitlement_template_id— those are internal storage concerns.
Super-admin surface
Section titled “Super-admin surface”Not touched.
Data model changes
Section titled “Data model changes”The full DDL is in the migration outline at the bottom; here’s the per-table summary.
| Table | Change | Rationale |
|---|---|---|
passes.pass_entitlement_covered_extras (NEW) | id, entitlement_template_id (fk → pass_entitlement_template, on delete restrict), extra_id (fk → activity_extras, on delete restrict), quantity (>= 1), created_at, updated_at, unique(entitlement_template_id, extra_id), index(entitlement_template_id), index(extra_id) | D1 — coverage join table |
activities.activity_extras.is_active | NO CHANGE — column already exists (line 302). Document presence in ADR; AC-5 is satisfied by existing schema. | D9 |
bookings.booking_extras.price_paid | NEW column decimal(10,2) not null default '0' | D6 — per-unit billed amount |
bookings.booking_extras.covered_by_entitlement_id | NEW column uuid null references passes.customer_entitlement(id) on delete set null, indexed | D6 — answers “which entitlement covered this row” without re-resolution |
Indexes
Section titled “Indexes”pass_entitlement_covered_extras (entitlement_template_id)— for the read path “give me the covered extras for this entitlement template”, which fires on every pass-template GET and on every booking that uses a pass.pass_entitlement_covered_extras (extra_id)— for the inverse read path “which pass templates cover this extra” (used during admin soft-delete UX to warn the admin about referenced templates; future refactors may rely on it).booking_extras (covered_by_entitlement_id)— for AC-2 reporting (sum of covered units per entitlement).- The unique constraint
(entitlement_template_id, extra_id)doubles as a composite index for both read paths.
Migration ordering (relevant for backend phase)
Section titled “Migration ordering (relevant for backend phase)”Drizzle emits a single migration with:
CREATE TABLE passes.pass_entitlement_covered_extrasplus FKs and indexes.ALTER TABLE bookings.booking_extras ADD COLUMN price_paid(with default 0 — backfill is automatic for existing rows; existing rows have correct economic meaning since they were all NOT covered, so price_paid should equal price; see “Backfill” below).ALTER TABLE bookings.booking_extras ADD COLUMN covered_by_entitlement_id uuid nullplus FK + index.
Backfill
Section titled “Backfill”Existing booking_extras rows: price_paid defaults to '0' per the new
column default. This is wrong for existing rows — every existing row
represents charged units, so price_paid should equal price. The migration
follows the table create with a one-time UPDATE:
UPDATE bookings.booking_extras SET price_paid = price WHERE price_paid = '0';This is safe because:
- No existing row was the result of pass coverage (the bug this ticket fixes
is “pass extras silently weren’t charged” — meaning no covered-by-pass
row exists historically with
price_paid = 0andprice > 0; every current row IS effectively a not-covered row in the new model); - The UPDATE is idempotent — re-running converges.
covered_by_entitlement_id defaults to NULL for all existing rows, which
correctly represents “no entitlement covered this” — matches reality
because the buggy historical flow never recorded coverage.
Backfill is documented in the migration outline below. The team rule
“migrations generated only” applies: backend-dev runs drizzle-kit generate
and adds the UPDATE statement to the generated migration manually (the
migration-safety-check skill confirms the UPDATE is idempotent).
Backend module placement
Section titled “Backend module placement”All work lands in existing libs:
libs/features/passes/src/lib/dto/passes-admin.dto.ts— extendCreatePassEntitlementDtoandUpdatePassEntitlementDtowithcoveredExtras. Add aCoveredExtraTemplateDtoclass. Add server-side validators:extraIdexists, belongs to the entitlement’sactivityId,quantity >= 1, no duplicateextraIdwithin one entitlement payload.libs/features/passes/src/lib/dto/passes-client.dto.ts— extendMyEntitlementDto,MyEntitlementForActivityDto, andPassEntitlementClientDto(or whichever class corresponds to the catalogue’s per-entitlement shape) withcoveredExtras: CoveredExtraClientDto[].libs/features/passes/src/lib/services/passes.service.ts(admin):- on
findAll/findOne: joinpass_entitlement_covered_extrasand return per-entitlementcoveredExtras: [{extraId, quantity}]. - on
create/update: insert/replacepass_entitlement_covered_extrasrows for each entitlement; replace-in-full for update.
- on
libs/features/passes/src/lib/services/passes-client.service.ts:- on
listAvailable: same join, return enriched per-entitlement shape (extraId, name, price, quantity, isActive). - on
listMineand themy-entitlementsendpoint: same join.
- on
libs/features/activities/src/lib/dto/create-client-booking.dto.ts— addextrasPaymentMethod?: ExtrasPaymentMethodfield withclass-validatorrules (D4); declare theExtrasPaymentMethodTS enum (a TypeScript subset ofBookingPaymentMethodto keep the runtime validator sharp).libs/features/activities/src/lib/services/bookings-client.service.ts:- introduce
bookWithPassAndExtrasthat handles the split. - the existing
bookWithPassstays — used when there are zero extras. resolveExtrasenriches each item with the entitlement’s coverage (left-join thepass_entitlement_covered_extrasrow for the selectedcustomerEntitlementId’s template).- the existing post-branch
insert(bookingExtras)block is moved INTO the per-branch flow so the pass-with-extras branch can split rows (and the existing branches just emit one row per extra as today). - the wallet-debit / bonus-debit / on-site path for
extrasPaymentMethodis delegated to existing services (walletService.debit,companyCustomers.bonusBalancedecrement) inside the samedb.transaction(...).
- introduce
libs/shared/data-access-db/src/lib/schema/passes.schema.ts— declare the newpass_entitlement_covered_extrastable + relations.libs/shared/data-access-db/src/lib/schema/bookings.schema.ts— addpricePaidandcoveredByEntitlementIdcolumns + relations.
No new Nx library. No new shared utility. Validation rules ride
class-validator decorators inline.
Frontend implications
Section titled “Frontend implications”tktspace-business
Section titled “tktspace-business”- Pass-template form UI gains a “Covered extras” multi-select per
entitlement row. UX:
- Below each entitlement row (which already picks an
activityandsessionsLimit), render a sub-section listing the activity’s extras (loaded from the existing activity-detail endpoint already used for the activity edit page). - Each row: a checkbox “covered” + a
tuiInputNumberforquantity(default 1, min 1). Disabled when the checkbox is off. - Submit shape per entitlement:
coveredExtras: [{extraId, quantity}], omitting unchecked rows.
- Below each entitlement row (which already picks an
- API client regen:
npm run generate:apiafter the contract is published. - Files touched (from
client/src/app/features/dashboard/activities/):pages/pass-form/pass-form.page.ts— wires up the new sub-form per entitlement.pages/pass-form/pass-form.page.html— Taiga UI widgets:tuiInputNumber,tuiCheckbox,tuiAccordion(collapsible per-entitlement).
- No router or sidebar changes. Permissions unchanged (
MANAGE_ACTIVITIES).
tktspace-mobile-app/apps/gym_app
Section titled “tktspace-mobile-app/apps/gym_app”- New mobile UX (AC-3, AC-4):
- Checkout extras list: per-extra coverage badge (“Covered by pass: N free”)
- live
X covered, Y paidcount below the qty stepper.
- live
- Pass-detail screen: “Covered extras” section grouped by activity, with
name × quantityrows; soft-deleted extras dimmed with “(removed)” suffix.
- Checkout extras list: per-extra coverage badge (“Covered by pass: N free”)
- Files touched:
apps/gym_app/lib/pages/passes/my_pass_detail_page.dart— new section composing aCoveredExtrasListwidget (fed byMyPassDto.entitlements[].coveredExtras).- The booking summary step’s extras list (currently in
packages/checkout) gets a coverage-aware sub-widget.
- Mobile packages touched (per spec frontmatter):
packages/api— auto-regenerated from the new contract (melos run sync:spec && melos run generate:api).packages/core—passes_repository.dartandpasses_state.dartget typed access to the newcoveredExtrasfield; no logic change beyond the regenerated DTOs.packages/checkout— extras list UI gains coverage rendering. The existingBookingConfig/ summary step inpackages/checkout/lib/src/steps/summary_step.dartis updated to:- resolve
coveredExtrasfrom the selected entitlement; - compute the split per extra and render the badge + count;
- show the new payment-method picker below the extras (Taiga-equivalent
Flutter chips:
ON_SITE,WALLET,BONUS) ONLY when at least one not-covered unit exists; - forward
extrasPaymentMethodandcustomerEntitlementIdto the booking-create call.
- resolve
packages/ui— new shared widgetcovered_extra_badge.dart(the small “Covered by pass: N free” pill) andcovered_extras_list.dart(the grouped list used on pass detail). Both are presentation-only — receive a typed model, render it. They live inpackages/uito be reusable from both the checkout package and gym_app’s pass-detail page.
apps/tickets_app
Section titled “apps/tickets_app”Not touched (out of scope per spec).
tktspace-web, tktspace-landing
Section titled “tktspace-web, tktspace-landing”Not touched (out of scope per spec).
Cross-cutting design notes
Section titled “Cross-cutting design notes”AC-2 — atomic split inside the booking transaction
Section titled “AC-2 — atomic split inside the booking transaction”Concretely, the new bookWithPassAndExtras flow:
db.transaction(tx => { validateAndUseEntitlement(entitlementId, customerId, activityId) // bumps sessionsUsed by 1 resolveExtras(...) // existing: validates extras, snapshots prices splitByCoverage(extras, entitlement.template.coveredExtras) // pure function -> [{covered, notCovered}] insert booking row (price = sum of NOT-covered prices, walletDebited flag set per extrasPaymentMethod) for each extra: if covered.qty > 0: insert booking_extras (qty=covered.qty, price=snapshot, price_paid=0, covered_by_entitlement_id=entitlementId) if notCovered.qty > 0: insert booking_extras (qty=notCovered.qty, price=snapshot, price_paid=snapshot, covered_by_entitlement_id=null) if extrasPaymentMethod == WALLET: walletService.debit(extrasNotCoveredTotal) if extrasPaymentMethod == BONUS: companyCustomers.bonusBalance -= extrasNotCoveredTotal (existing decrement) if extrasPaymentMethod == ON_SITE: nothing (booking confirmed without a debit, same as ON_SITE today)})If the wallet debit fails (insufficient funds), the transaction rolls back —
the booking is never created, the entitlement counter is restored
automatically by Drizzle (since validateAndUseEntitlement mutates within
the same tx). The caller gets HTTP 400 with the existing wallet-balance
error.
The existing bookWithPass is kept intact — it’s still the right path when
there are NO extras. The decision to branch is a simple extras.length === 0
check at the top of the existing case BookingPaymentMethod.PASS: switch.
AC-7 — explicit pick propagation
Section titled “AC-7 — explicit pick propagation”- Mobile: when more than one entitlement matches the activity, the existing
UsePassPickerwidget (already declared in the implement-passes ADR,packages/ui/lib/src/components/use_pass_picker.dart) renders a sheet listing each entitlement with itscoveredExtrasas small chips. The user taps one; selection is forwarded ascustomerEntitlementId. - When exactly one matches: auto-selected (no UI step).
- When zero match: pass payment option is hidden.
- Backend trusts the structure of the supplied
customerEntitlementId(it does not auto-resolve between multiple matches), but never trusts its semantic validity — D5 guards run before any state mutation. The guards (ownership / activity-match / status-usable / sessions-remaining) defend against IDOR and stale-id replay; failures map to localized error codes (errors.pass.entitlement_not_owned403,errors.pass.entitlement_activity_mismatch422,errors.pass.entitlement_unusable422,errors.pass.entitlement_exhausted422). See D5 for the full guard table. - If
customerEntitlementIdis absent onpaymentMethod=PASS, returns 422 (unchanged from today’s behavior). - AC-2’s split computation (D6) operates on the validated entitlement — the split logic never runs against unvalidated input.
Soft-delete cascade summary
Section titled “Soft-delete cascade summary”activity_extras.is_active = false is set by the existing soft-delete
flow (no schema change). The exhaustive read/write enumeration of which
queries filter and which do not lives in D9 — see the table there.
Per-path failure codes:
- Catalogue + booking-create reads/writes filter
is_active = true. Replay of an inactiveextraIdon booking-create returns HTTP 422 with codeerrors.extras.no_longer_available(N5). - Business
coveredExtras[]validator on pass-template create/update rejects inactive extras with HTTP 400 and codeerrors.extras.cannot_cover_inactive. - Pass-detail / catalogue /
my-entitlementsreads pass through inactive extras withisActive: falseso the mobile dims and appends “(removed)”. - Soft-delete vs admin template-edit removal are distinct cases — see
D11 (B3): only soft-delete produces a “(removed)” label; admin removing
the row from
coveredExtras[]simply drops it from the response.
Test plan per AC
Section titled “Test plan per AC”| AC | Surface | Test file path (suggested) |
|---|---|---|
| AC-1 | tktspace-business | client/src/app/features/dashboard/activities/pages/pass-form/pass-form.page.spec.ts (covered-extras sub-form) |
| AC-2 | backend | libs/features/activities/src/lib/services/bookings-client.service.spec.ts (split logic); apps/api-e2e/src/bookings/booking-with-pass-extras.e2e-spec.ts |
| AC-3 | gym_app | packages/checkout/test/steps/summary_step_test.dart (badge rendering + payment-method picker visibility) |
| AC-4 | gym_app | apps/gym_app/test/pages/passes/my_pass_detail_page_test.dart (covered-extras list, soft-deleted suffix) |
| AC-5 | backend + gym_app | libs/features/activities/src/lib/services/extras.service.spec.ts (soft-delete preserves id); apps/gym_app/integration_test/passes_soft_deleted_extra_test.dart |
| AC-6 | _workflow | This MR. |
| AC-7 | gym_app | apps/gym_app/integration_test/booking_with_pass_picker_test.dart |
| AC-8 | backend | apps/api-e2e/src/bookings/cancel-with-pass-mixed-extras.e2e-spec.ts (existing cancel flow, refund to wallet for not-covered extras) |
- Booking-extras row count bump (1 → up to 2 per requested extra) (N4):
Two distinct downstream consequences:
- Reports that count
booking_extrasrows as a proxy for “extras sold” become inaccurate (a single requested extra can now produce two rows: covered + not-covered). Mitigation: reports must useSUM(quantity)orSUM(price_paid * quantity)instead ofCOUNT(*). Documented so ops/analytics owners are aware. - Mobile clients iterating extras with
extraIdas a unique key will collide — the sameextraIdmay appear twice on a booking response. Recommendation: iterate by the composite key(extraId, coveredByEntitlementId)(the two-row case is exactly disambiguated bycoveredByEntitlementIdnon-null vs null). The deterministic ordering in D6/N2 (ORDER BY extra_id ASC, covered_by_entitlement_id NULLS LAST) guarantees the covered row precedes the not-covered row when both exist. Mobile dev MUST update any list-key / reduce-to-map logic inpackages/checkoutandapps/gym_appaccordingly; the mobile API regen alone is NOT sufficient.
- Reports that count
- Soft-deleted extras showing on pass detail: a customer might wonder why an extra they “got with their pass” is now greyed out. The “(removed)” suffix is the explicit answer; UA copy is provided per AC-4. Risk is small but non-zero; future work might offer an “explain” tooltip.
- Backfill correctness: the
price_paid = priceUPDATE assumes every existingbooking_extrasrow was effectively not-covered (correct given the bug). If the team ever ran a manual data fix that already produced a covered row (withprice = '0'for a free extra), the UPDATE preserves that asprice_paid = '0'— also correct. No row is misclassified. - Quantity capped per-booking, not per-pass-lifetime: a customer with
unlimited pass usage gets
coveredQtyextras per booking, multiplied across many bookings. This matches the spec’s deliberate per-session model — but operators should be aware that “covers towel: 2” doesn’t mean “2 towels for the entire pass life”. Documented in the admin form helper text. - Race when admin soft-deletes an extra mid-checkout: customer’s
checkout has a covered extra with
isActive=truesnapshot in mobile state; admin flips it to false; customer submits booking. Backend rejects with HTTP 422 and codeerrors.extras.no_longer_available(N5) — the mobile already has the localized-message pipeline so it renders the right copy without code changes beyond the regenerated DTO. The error UX is the same as the existing “extra removed during checkout” flow today for non-pass bookings. - Contract drift between business
CoveredExtraTemplateDtoand clientCoveredExtraClientDto: the two intentionally diverge (D2). If a future ticket adds a field to the template DTO, the client DTO must be reviewed in lockstep. Mitigation: comment in both contracts cross- referencing this ADR.
Rollout plan
Section titled “Rollout plan”No feature flag. Single coordinated rollout:
_workflowMR (this ADR + contract patches) lands onmain.tktspace-backendMR — schema changes,drizzle-kit generate, runmigration-safety-checkskill on the produced SQL, implement the new service code, add tests for AC-2/AC-5/AC-8 per the test-plan table.- In parallel:
tktspace-businessMR —npm run generate:apithen wires the covered-extras sub-form. - In parallel:
tktspace-mobile-appMR —melos run sync:spec && melos run generate:api, implementspackages/checkout+packages/uiwidget changes and the gym_app pass-detail covered-extras section.
Backwards-compat:
- Pass-template create/update:
coveredExtrasis OPTIONAL on input. Existing templates have emptycoveredExtras→ no charging change for existing pass bookings (same as today’s bug). Once the mobile ships and templates start declaring coverage, charges flow through. - Booking create:
extrasPaymentMethodis OPTIONAL on input. Old client builds (without coverage UI) sendingpaymentMethod=PASSwith extras get HTTP 422 if any extra is not covered (no existing template covers anything — see above — so MOST old-client requests will be rejected once a template ships coverage). Mitigation: see “Rollout gating” below. booking_extras.price_paiddefault: backfilled by the migration (see “Backfill”). No behavior change for legacy reports that readprice.
Rollout gating (C5 — backwards-compat gate)
Section titled “Rollout gating (C5 — backwards-compat gate)”The customer-facing window in which “old clients break once a template ships coverage” is closed by a concrete three-step gate. Backend-dev and ops MUST follow this order:
-
Backend deploys first. The
coveredExtrasfield is OPTIONAL on pass-template input and defaults to an empty array on output. Existing pass-template rows have an emptypass_entitlement_covered_extrasjoin — empty coverage is the no-op default and produces the SAME booking behavior as today (no rows split, noextrasPaymentMethodrequired). The new error codes (errors.booking.extras_payment_method_required,errors.booking.extras_payment_method_unexpected,errors.extras.no_longer_available,errors.extras.cannot_cover_inactive, plus the fourerrors.pass.entitlement_*guards) ship to the i18n bundles in the same deploy. Backend-only rollout has zero observable customer impact because no template covers anything yet. -
Mobile ships next (gym_app build with the regenerated
packages/api). Build-time constantMIN_BACKEND_API_VERSION = "passes-per-extra-coverage@1"lives inpackages/core/lib/src/config/api_version.dart(mirrors the existing build-time constants pattern). The mobile rejects un-recognized booking error codes from older backends gracefully — the localized-message extraction pipeline (already in place from the implement-passes effort) falls back to the server-suppliedmessagefield for any code not in its known set, so old-style errors continue to render. New mobile against old backend is therefore safe (only new error codes are unrecognized; the message renders regardless). -
Templates with non-empty
coveredExtrasstay disabled in production until BOTH conditions hold:- Backend version with the AC-2 split logic is deployed (step 1 complete).
- Mobile force-update or org-level minimum-version policy is bumped
to the gym_app build that handles
extrasPaymentMethod(step 2 complete and rolled out to ≥ 95% of pass-holders for the affected org, per the existing Sentry release-adoption dashboard).
Step 3.2 is a manual ops step. The runbook entry: “Before enabling coverage on any pass template in $ORG, verify that the org-level minimum gym_app version policy is at least version X — the build that handles
extrasPaymentMethod. The release engineer announces the version in #releases when the mobile ships; ops bumps the per-org minimum after sufficient rollout. Until then, all pass-templates in the org keepcoveredExtras = [].”
This sequence guarantees that no customer-facing booking flow EVER sees a 422 / 400 from the new validation paths until they are running a build that knows how to render the picker. The only failure mode remaining is the deliberate one (admin soft-deletes an extra mid-checkout — already handled by the localized-message pipeline, see Risks section).
Stop scope (non-goals — explicit)
Section titled “Stop scope (non-goals — explicit)”To prevent creep:
- No refunds for past bookings that incorrectly charged extras.
- No change to per-session consumption (still ONE session per booking).
- No multi-currency handling.
- No granular per-extra refund on cancel (cancellation flow is unchanged; AC-8 explicit).
- No
tickets_app, notktspace-web, notktspace-landingwork. - No LIQPAY for
extrasPaymentMethodin V1 (D8). Not even with TODOs. - No “pay extras later” / DEFER for
extrasPaymentMethodin V1 (D8). - No idempotency on booking-with-pass-extras (the existing booking endpoint has no idempotency; this ticket does not introduce it).
- No analytics dashboard for “revenue from pass-covered extras”. Reporting
surfaces are already queryable by the new
price_paidandcovered_by_entitlement_idcolumns; building a dashboard is a separate ticket. - No ADMIN-side “issue booking with pass coverage” path on
/api/business/*. This ADR scopes coverage application to the customer-driven/api/client/companies/{id}/bookingsPOST. The admin cannot today create bookings on behalf of a customer via the business surface, so there’s nothing to extend. (If that endpoint is added later, this ADR’s split logic is the obvious reuse target.)
DB migration outline (drizzle-kit will generate the actual SQL)
Section titled “DB migration outline (drizzle-kit will generate the actual SQL)”NOTE: Per the team rule “migrations generated only” the actual migration
SQL is produced by drizzle-kit generate after the schema files are
edited. The outline below documents the EXPECTED shape so backend-dev can
verify the generator output matches intent, and so ops can plan the
deploy.
-- 1. Coverage join tableCREATE TABLE passes.pass_entitlement_covered_extras ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entitlement_template_id uuid NOT NULL REFERENCES passes.pass_entitlement_template(id) ON DELETE RESTRICT, extra_id uuid NOT NULL REFERENCES activities.activity_extras(id) ON DELETE RESTRICT, quantity integer NOT NULL CHECK (quantity >= 1), created_at timestamp NOT NULL DEFAULT now(), updated_at timestamp NOT NULL DEFAULT now(), UNIQUE (entitlement_template_id, extra_id));
CREATE INDEX pass_entitlement_covered_extras_template_id_idx ON passes.pass_entitlement_covered_extras (entitlement_template_id);
CREATE INDEX pass_entitlement_covered_extras_extra_id_idx ON passes.pass_entitlement_covered_extras (extra_id);
-- 2. Booking-extra row split trackingALTER TABLE bookings.booking_extras ADD COLUMN price_paid decimal(10,2) NOT NULL DEFAULT '0';
ALTER TABLE bookings.booking_extras ADD COLUMN covered_by_entitlement_id uuid NULL REFERENCES passes.customer_entitlement(id) ON DELETE SET NULL;
CREATE INDEX booking_extras_covered_by_entitlement_id_idx ON bookings.booking_extras (covered_by_entitlement_id);
-- 3. Backfill price_paid for existing rows (every existing row is-- effectively a not-covered row in the new model — see Backfill above)UPDATE bookings.booking_extras SET price_paid = price WHERE price_paid = '0';activities.activity_extras.is_active already exists (line 302 of
activities.schema.ts); NO migration needed for AC-5 soft-delete storage.
The behavior change is in the QUERY layer (filter is_active = true on the
catalogue path; pass-through is_active on the pass-detail path) — no
schema work.
STATUS: READY_FOR_REVIEW