Skip to content

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”

PROPOSED

ACPrimary decisionsNotes
AC-1D1, D2 (template shape), D3, D10Storage join table + business response shape + per-booking quantity semantics.
AC-2D4, D5 (ownership/activity/status guards), D6, D7Split logic, defensive entitlement validation, row split, and atomic transaction.
AC-3D2 (client shape), D4, D8Per-extra badge + payment-method picker visibility tied to allowed ExtrasPaymentMethod values.
AC-4D2 (client shape isActive), D9, D11Pass-detail returns soft-deleted extras with isActive: false. Snapshot-vs-current semantics pinned in D11 (B3).
AC-5D9Soft-delete cascade — exhaustive read/write enumeration.
AC-6AllContract patches for both surfaces.
AC-7D5Explicit pick + server-side defensive validation of customerEntitlementId.
AC-8D6covered_by_entitlement_id + price_paid keep cancellation flow unchanged but reportable.

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 minus PASS);
  • 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. The activities.activity_extras.is_active column ALREADY EXISTS in the schema (tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts line 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.

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 === PASS AND the resolved split has ZERO not-covered units → extrasPaymentMethod MUST be omitted; if present, server returns HTTP 400 with code errors.booking.extras_payment_method_unexpected (avoid charging a customer who has no charge to apply to).
  • If paymentMethod === PASS AND the resolved split has at least one not-covered unit → extrasPaymentMethod is REQUIRED; if absent, server returns HTTP 422 with code errors.booking.extras_payment_method_required per the spec.
  • If paymentMethod !== PASSextrasPaymentMethod is ignored / rejected (the existing paymentMethod already 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 customerEntitlementId for paymentMethod=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 customerEntitlementId blindly. Before consuming it (decrementing the session counter, computing the covered/not-covered split, charging the not-covered units, or inserting any booking_extras row), bookings-client.service.ts bookWithPassAndExtras (and the pre-existing bookWithPass for 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 what nestjs-i18n actually keys against — the bare <surface>.<code> form would silently fall through to defaultValue at runtime and ship un-localized copy to the client. Backend-dev MUST mirror the exact key form into apps/api/src/i18n/{en,uk}/errors.json.

GuardCheckFailure HTTPError code (nestjs-i18n key)
OwnershipThe entitlement’s parent customer_pass.customer_id equals the authenticated user’s customer id for the company.403 Forbiddenerrors.pass.entitlement_not_owned
Activity matchThe entitlement’s template activity_id equals the booking’s activity_id.422 Unprocessable Entityerrors.pass.entitlement_activity_mismatch
Status usableThe entitlement’s parent customer_pass.status is in {ACTIVE, PAUSED}. EXPIRED / CANCELLED / AWAITING_PAYMENT / PENDING are rejected.422errors.pass.entitlement_unusable
Sessions remainingIf template.sessions_limit IS NOT NULL then customer_entitlement.sessions_used < template.sessions_limit. Unlimited entitlements (sessions_limit IS NULL) skip this check.422errors.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 of activity_extras.price at booking time (same value the existing price column already stores — see “Why both price and price_paid” below).
  • covered_by_entitlement_id uuid null references passes.customer_entitlement(id) on delete set null — set to the resolved customerEntitlementId for 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.price snapshot, 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 LAST

NULLS 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 ALL booking_extras rows 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.

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.

PathDirectionFilterReason
GET /api/client/activities/:id extras section (mobile catalogue)readWHERE activity_extras.is_active = trueCustomers cannot see / select removed extras when starting a booking.
Mobile booking-create extras lookup before charge (resolveExtras inside bookings-client.service.ts)readWHERE activity_extras.is_active = trueDefence — 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)readWHERE activity_extras.is_active = trueAdmin 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)readNO 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[] validatorwriteReject any extraId whose activity_extras.is_active = false → HTTP 400 with code errors.extras.cannot_cover_inactiveAdmin 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)writeEach 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_available and errors.extras.cannot_cover_inactive (uk + en).
  • The pass-detail / catalogue read paths intentionally bypass the is_active filter so the customer sees the historical reference; the isActive boolean on each CoveredExtraClientDto carries the truth to the UI. The contract response examples for those paths must show isActive: false is 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:

  1. 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 via pass_entitlement_covered_extras (the FK is ON DELETE RESTRICT, and the soft-delete does not delete the row). The pass-detail / catalogue responses keep returning the entry, with isActive: false. The mobile UI dims the row and appends a localized “(removed)” / “(вилучено)” suffix. This is the only case where “(removed)” appears.

  2. Admin edits the template to drop coverage (admin removes the extra from coveredExtras[] on the entitlement via PUT): the row disappears from pass_entitlement_covered_extras entirely. 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.

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_template are 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=PASS is a valid value of the parent enum, and a customer cannot legally pass extrasPaymentMethod=PASS (would mean “use a pass to pay for extras”, which is the original bug). Encoding “the parent enum minus one value” via JSON Schema not is supported but not all client generators (especially Dart chopper_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_extras API to fan out — unrelated reports break.
  • quantity already exists on the table; adding price_paid next 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 optional coveredExtras: CoveredExtraTemplateDto[].
  • UpdatePassEntitlementDto — adds optional coveredExtras: CoveredExtraTemplateDto[] with replace-in-full semantics.
  • PassEntitlementTemplateResponseDto — adds coveredExtras: 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 optional extrasPaymentMethod: ExtrasPaymentMethod with the validation rules in D4.
  • New schema ExtrasPaymentMethod: enum [ON_SITE, WALLET, BONUS] (D8).

Spec-divergence flag (N1): the spec language describes extrasPaymentMethod as “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], dropping LIQPAY and DEFER. 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 — adds coveredExtras: CoveredExtraClientDto[] (always present, possibly empty) so the booking summary checkout can resolve coverage without a second call.
  • MyEntitlementForActivityDto — adds coveredExtras: CoveredExtraClientDto[]. Used by the my-entitlements endpoint that already feeds the entitlement picker; the picker shows “covers towels: 2” inline.
  • PassEntitlementClientDto — adds coveredExtras: 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 }. Includes isActive so the pass-detail render can dim and append “(removed)”; includes name and price so 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, equals price for 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 CoveredExtraTemplateDto has only {extraId, quantity} because the admin already has the activity’s extras list — sending name, price back would be stale-data.
  • Client CoveredExtraClientDto has {extraId, name, price, quantity, isActive} because the mobile checkout / pass-detail screens need to render names inline AND need the isActive flag 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.

Not touched.

The full DDL is in the migration outline at the bottom; here’s the per-table summary.

TableChangeRationale
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_activeNO CHANGE — column already exists (line 302). Document presence in ADR; AC-5 is satisfied by existing schema.D9
bookings.booking_extras.price_paidNEW column decimal(10,2) not null default '0'D6 — per-unit billed amount
bookings.booking_extras.covered_by_entitlement_idNEW column uuid null references passes.customer_entitlement(id) on delete set null, indexedD6 — answers “which entitlement covered this row” without re-resolution
  • 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:

  1. CREATE TABLE passes.pass_entitlement_covered_extras plus FKs and indexes.
  2. 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).
  3. ALTER TABLE bookings.booking_extras ADD COLUMN covered_by_entitlement_id uuid null plus FK + index.

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 = 0 and price > 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).

All work lands in existing libs:

  • libs/features/passes/src/lib/dto/passes-admin.dto.ts — extend CreatePassEntitlementDto and UpdatePassEntitlementDto with coveredExtras. Add a CoveredExtraTemplateDto class. Add server-side validators: extraId exists, belongs to the entitlement’s activityId, quantity >= 1, no duplicate extraId within one entitlement payload.
  • libs/features/passes/src/lib/dto/passes-client.dto.ts — extend MyEntitlementDto, MyEntitlementForActivityDto, and PassEntitlementClientDto (or whichever class corresponds to the catalogue’s per-entitlement shape) with coveredExtras: CoveredExtraClientDto[].
  • libs/features/passes/src/lib/services/passes.service.ts (admin):
    • on findAll / findOne: join pass_entitlement_covered_extras and return per-entitlement coveredExtras: [{extraId, quantity}].
    • on create / update: insert/replace pass_entitlement_covered_extras rows for each entitlement; replace-in-full for update.
  • 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 listMine and the my-entitlements endpoint: same join.
  • libs/features/activities/src/lib/dto/create-client-booking.dto.ts — add extrasPaymentMethod?: ExtrasPaymentMethod field with class-validator rules (D4); declare the ExtrasPaymentMethod TS enum (a TypeScript subset of BookingPaymentMethod to keep the runtime validator sharp).
  • libs/features/activities/src/lib/services/bookings-client.service.ts:
    • introduce bookWithPassAndExtras that handles the split.
    • the existing bookWithPass stays — used when there are zero extras.
    • resolveExtras enriches each item with the entitlement’s coverage (left-join the pass_entitlement_covered_extras row for the selected customerEntitlementId’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 extrasPaymentMethod is delegated to existing services (walletService.debit, companyCustomers.bonusBalance decrement) inside the same db.transaction(...).
  • libs/shared/data-access-db/src/lib/schema/passes.schema.ts — declare the new pass_entitlement_covered_extras table + relations.
  • libs/shared/data-access-db/src/lib/schema/bookings.schema.ts — add pricePaid and coveredByEntitlementId columns + relations.

No new Nx library. No new shared utility. Validation rules ride class-validator decorators inline.

  • Pass-template form UI gains a “Covered extras” multi-select per entitlement row. UX:
    • Below each entitlement row (which already picks an activity and sessionsLimit), 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 tuiInputNumber for quantity (default 1, min 1). Disabled when the checkbox is off.
    • Submit shape per entitlement: coveredExtras: [{extraId, quantity}], omitting unchecked rows.
  • API client regen: npm run generate:api after 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).
  • New mobile UX (AC-3, AC-4):
    • Checkout extras list: per-extra coverage badge (“Covered by pass: N free”)
      • live X covered, Y paid count below the qty stepper.
    • Pass-detail screen: “Covered extras” section grouped by activity, with name × quantity rows; soft-deleted extras dimmed with “(removed)” suffix.
  • Files touched:
    • apps/gym_app/lib/pages/passes/my_pass_detail_page.dart — new section composing a CoveredExtrasList widget (fed by MyPassDto.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/corepasses_repository.dart and passes_state.dart get typed access to the new coveredExtras field; no logic change beyond the regenerated DTOs.
    • packages/checkout — extras list UI gains coverage rendering. The existing BookingConfig / summary step in packages/checkout/lib/src/steps/summary_step.dart is updated to:
      • resolve coveredExtras from 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 extrasPaymentMethod and customerEntitlementId to the booking-create call.
    • packages/ui — new shared widget covered_extra_badge.dart (the small “Covered by pass: N free” pill) and covered_extras_list.dart (the grouped list used on pass detail). Both are presentation-only — receive a typed model, render it. They live in packages/ui to be reusable from both the checkout package and gym_app’s pass-detail page.

Not touched (out of scope per spec).

Not touched (out of scope per spec).

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.

  • Mobile: when more than one entitlement matches the activity, the existing UsePassPicker widget (already declared in the implement-passes ADR, packages/ui/lib/src/components/use_pass_picker.dart) renders a sheet listing each entitlement with its coveredExtras as small chips. The user taps one; selection is forwarded as customerEntitlementId.
  • 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_owned 403, errors.pass.entitlement_activity_mismatch 422, errors.pass.entitlement_unusable 422, errors.pass.entitlement_exhausted 422). See D5 for the full guard table.
  • If customerEntitlementId is absent on paymentMethod=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.

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 inactive extraId on booking-create returns HTTP 422 with code errors.extras.no_longer_available (N5).
  • Business coveredExtras[] validator on pass-template create/update rejects inactive extras with HTTP 400 and code errors.extras.cannot_cover_inactive.
  • Pass-detail / catalogue / my-entitlements reads pass through inactive extras with isActive: false so 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.
ACSurfaceTest file path (suggested)
AC-1tktspace-businessclient/src/app/features/dashboard/activities/pages/pass-form/pass-form.page.spec.ts (covered-extras sub-form)
AC-2backendlibs/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-3gym_apppackages/checkout/test/steps/summary_step_test.dart (badge rendering + payment-method picker visibility)
AC-4gym_appapps/gym_app/test/pages/passes/my_pass_detail_page_test.dart (covered-extras list, soft-deleted suffix)
AC-5backend + gym_applibs/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_workflowThis MR.
AC-7gym_appapps/gym_app/integration_test/booking_with_pass_picker_test.dart
AC-8backendapps/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:
    1. Reports that count booking_extras rows as a proxy for “extras sold” become inaccurate (a single requested extra can now produce two rows: covered + not-covered). Mitigation: reports must use SUM(quantity) or SUM(price_paid * quantity) instead of COUNT(*). Documented so ops/analytics owners are aware.
    2. Mobile clients iterating extras with extraId as a unique key will collide — the same extraId may appear twice on a booking response. Recommendation: iterate by the composite key (extraId, coveredByEntitlementId) (the two-row case is exactly disambiguated by coveredByEntitlementId non-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 in packages/checkout and apps/gym_app accordingly; the mobile API regen alone is NOT sufficient.
  • 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 = price UPDATE assumes every existing booking_extras row was effectively not-covered (correct given the bug). If the team ever ran a manual data fix that already produced a covered row (with price = '0' for a free extra), the UPDATE preserves that as price_paid = '0' — also correct. No row is misclassified.
  • Quantity capped per-booking, not per-pass-lifetime: a customer with unlimited pass usage gets coveredQty extras 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=true snapshot in mobile state; admin flips it to false; customer submits booking. Backend rejects with HTTP 422 and code errors.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 CoveredExtraTemplateDto and client CoveredExtraClientDto: 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.

No feature flag. Single coordinated rollout:

  1. _workflow MR (this ADR + contract patches) lands on main.
  2. tktspace-backend MR — schema changes, drizzle-kit generate, run migration-safety-check skill on the produced SQL, implement the new service code, add tests for AC-2/AC-5/AC-8 per the test-plan table.
  3. In parallel: tktspace-business MR — npm run generate:api then wires the covered-extras sub-form.
  4. In parallel: tktspace-mobile-app MR — melos run sync:spec && melos run generate:api, implements packages/checkout + packages/ui widget changes and the gym_app pass-detail covered-extras section.

Backwards-compat:

  • Pass-template create/update: coveredExtras is OPTIONAL on input. Existing templates have empty coveredExtras → 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: extrasPaymentMethod is OPTIONAL on input. Old client builds (without coverage UI) sending paymentMethod=PASS with 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_paid default: backfilled by the migration (see “Backfill”). No behavior change for legacy reports that read price.

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:

  1. Backend deploys first. The coveredExtras field is OPTIONAL on pass-template input and defaults to an empty array on output. Existing pass-template rows have an empty pass_entitlement_covered_extras join — empty coverage is the no-op default and produces the SAME booking behavior as today (no rows split, no extrasPaymentMethod required). 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 four errors.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.

  2. Mobile ships next (gym_app build with the regenerated packages/api). Build-time constant MIN_BACKEND_API_VERSION = "passes-per-extra-coverage@1" lives in packages/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-supplied message field 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).

  3. Templates with non-empty coveredExtras stay disabled in production until BOTH conditions hold:

    1. Backend version with the AC-2 split logic is deployed (step 1 complete).
    2. 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 keep coveredExtras = [].”

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).

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, no tktspace-web, no tktspace-landing work.
  • No LIQPAY for extrasPaymentMethod in V1 (D8). Not even with TODOs.
  • No “pay extras later” / DEFER for extrasPaymentMethod in 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_paid and covered_by_entitlement_id columns; 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}/bookings POST. 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 table
CREATE 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 tracking
ALTER 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