Skip to content

ADR: Implement Passes (Абонементи)

ADR: Implement Passes (Абонементи)

Section titled “ADR: Implement Passes (Абонементи)”

PROPOSED

The backend passes feature is fully implemented (libs/features/passes/ in tktspace-backend):

  • 5 tables in PostgreSQL passes schema: pass, pass_entitlement_template, pass_price, customer_pass, customer_entitlement (libs/shared/data-access-db/src/lib/schema/passes.schema.ts).
  • Two HTTP surfaces wired through AdminApiModule (mounted at /api/business/*) and ClientApiModule (mounted at /api/client/*).
  • Daily scheduler (PassesSchedulerService) handling expiry, low-sessions notifications, and expiry reminders.
  • Wallet, MANUAL, and LiqPay payment paths integrated.
  • Booking integration via findAndConsumeEntitlementForBilling and validateAndUseEntitlement.

The gap is two-sided:

  1. The two OpenAPI contract files (contracts/business.openapi.yaml, contracts/client.openapi.yaml) are empty stubs — neither surface exposes any pass endpoint to consumers, so neither tktspace-business nor tktspace-mobile-app can talk to passes.
  2. Neither tktspace-business (operator UI) nor apps/gym_app (customer UI) has any pass-related screens or service code.

In addition, an audit identified seven correctness/quality issues in the backend code that must be fixed before consumers ship (see “Backend audit fixes” below). One additional fix (#8) was discovered during architecture review (recorded in this ADR’s “Backend audit findings (added during architecture)” subsection) — the spec is approved and is not edited. These changes are localized and do not alter the domain model.

This ADR pins down the contract shape (single source of truth for both surfaces), the audit fixes, the business panel module shape, the gym_app screens, the changes to all four affected mobile shared packages, and deep-link handling.

Keep the backend domain model as-is. Apply seven targeted backend audit fixes from the spec, plus one architecture-discovered fix (#8) that normalizes the PassesClientService.purchase return shape across its three payment-method branches. Bring the existing backend endpoints fully into the two OpenAPI contracts. Build operator UI in tktspace-business rooted at activities/passes. Build customer UI in apps/gym_app and a parallel PassPurchaseConfig flow inside packages/checkout that reuses payment plumbing (method picker, LiqPay redirect, pending screen) but NOT the booking item shape. Use a deep-link callback as the sole UX resolution path for off-app payments; the backend webhook remains the authoritative confirmation.

Alt 1: Keep backend as-is, ship only the frontends now, fix audit findings later

Section titled “Alt 1: Keep backend as-is, ship only the frontends now, fix audit findings later”

Trade-off: faster contract publish, but AC-13 (WALLET → ACTIVE on success) and AC-10 (mutually exclusive addSessions/subtractSessions) require backend changes. Tests that pass against the contract would fail against the actual backend. Rejected — the spec explicitly puts all 7 audit fixes in scope.

Alt 2: Build a unified purchase item type in packages/checkout that handles both bookings and passes via a discriminated union

Section titled “Alt 2: Build a unified purchase item type in packages/checkout that handles both bookings and passes via a discriminated union”

Trade-off: less duplication, single confirm handler. But it couples the existing booking flow to pass logic, contradicts the spec’s “do not modify booking flow” guarantee, and creates a wide blast radius for any future change to either side. Rejected — the spec explicitly mandates two distinct item types sharing only payment infrastructure.

Section titled “Alt 3: Adopt polling on the LiqPay pending screen as a backup to the deep-link”

Trade-off: faster perceived resolution if the deep-link is ever lost (cold launch, browser intercept). But it adds load on the backend, complicates state machine on the client, and the spec calls polling/WebSocket out as explicit non-goals for AC-14. Rejected — the spec mandates deep-link only.

Alt 4: Add an index on customer_entitlement.customer_pass_id regardless of measured need

Section titled “Alt 4: Add an index on customer_entitlement.customer_pass_id regardless of measured need”

Trade-off: cheap insurance against scheduler scan-time growth. But it adds write amplification on every pass-issuance and every booking-consume tx; for a feature whose consumer count is currently tiny, this is premature. Adopted partially — see Data-model decision below: we ship the index in the same migration as part of audit fix #6 because the JOIN added to the scheduler will scan customer_entitlement by customer_pass_id for every active pass per night, and a single migration is cheap to ship. Tradeoff is accepted.

Routing surface: a new child route at passes under ActivitiesRoute (client/src/app/features/dashboard/activities/activities.route.ts). The new route loads:

  • passes/PassesListPage (paginated list, active/inactive filter, toggle action).
  • passes/createPassFormPage (create).
  • passes/:id/editPassFormPage (edit; entitlements + prices replaced in full on save).

Sidebar: MenuService.menu (client/src/app/core/services/menu.service.ts) — append a third sub-item Passes to the existing Activities group with link: 'activities/passes'. The two existing items (Activities exact + Locations) are kept verbatim.

Customer-pass management lives on the existing customer detail page (client/src/app/features/dashboard/customers/pages/customer-detail/customer-detail.page.{ts,html,scss}). Adopt the same section pattern already used there for wallet/transactions — render a Passes section listing the customer’s passes with row-level actions: Issue (top-of-section), Pause, Resume, Adjust (modal), Cancel.

Modals/forms (new files — under client/src/app/features/dashboard/customers/modals/ and .../activities/modals/ mirroring existing wallet-adjust, location-edit, activity-extra-form patterns):

  • customers/modals/issue-pass/ — pick template, optional price, payment method (WALLET / MANUAL).
  • customers/modals/adjust-customer-pass/ — three numeric fields: extendDays, addSessions, subtractSessions, plus a select for customerEntitlementId. The form enforces mutual-exclusion at the field level and disables the subtract field when add is non-zero (and vice versa) so the request never violates the backend constraint.
  • activities/modals/pass-form/ (or page-level — see AC-2/3) — name, description, validityDays, notifySessionsRemaining, expiryNotifyDays, currency, cancelRefundPolicy, repeatable entitlements (activity + sessionsLimit), repeatable prices (name + price).

API client: regenerate via npm run generate:api (script: "generate:api": "ng-openapi-gen -c tools/gen/api-gateway.json"). Input file is tools/gen/swagger-api.json; the regenerate-business-api skill handles the swagger file refresh from the published business contract.

Permission requirements (mirror backend guards):

  • Pass-template endpoints require MANAGE_ACTIVITIES.
  • GET /customers/{customerId}/passes requires READ_CUSTOMERS.
  • All write operations on customer passes (issue/pause/resume/adjust/cancel) require MANAGE_CUSTOMERS.

UI must hide actions the operator lacks permission for; backend enforces by 403 anyway.

New router routes added to apps/gym_app/lib/router/app_router.dart:

  • /companies/:companyId/passesPassesCataloguePage (catalogue list).
  • /companies/:companyId/passes/:passIdPassDetailPage (template detail, price-variant picker, “Buy” button — opens the pass-purchase checkout).
  • /my-passesMyPassesPage (segmented control: All / Active).
  • /my-passes/:customerPassIdMyPassDetailPage (status, validUntil, per-activity sessionsRemaining, Cancel button if status in PENDING/ACTIVE).
  • /payments/successPaymentSuccessPage (deep-link landing — accepts passId and bookingId query params; reads which entitled pass is now ACTIVE from the relevant mine endpoint).
  • /payments/failurePaymentFailurePage (deep-link landing for cancel/error from gateway).
  • A pass-purchase entrypoint: /checkout/pass/:passIdCheckoutPage parameterized via PassPurchaseConfig (extra: passed via state.extra).

Booking integration (AC-16, AC-17): the existing booking flow (pages/activity/activity_page.dart and the slot/summary steps in packages/checkout) is augmented with a “Use pass” path on the summary step. When a pass is selected, the booking-create call sends the entitlement id; the existing BookingsClientService.create already consumes via validateAndUseEntitlement. No backend change required for AC-16/17 — only a new client-side UsePassPicker widget composed into the existing booking summary step, plus a call to getMyEntitlementsForActivity. See “Two distinct flows in packages/checkout” below for the explicit split between pass-purchase reuse and booking-with-pass edit of summary_step.dart.

Auto-regenerated from contracts/client.openapi.yaml via melos run sync:spec && melos run generate:api. The Chopper service file (packages/api/lib/src/...) gains methods (names follow the existing apiClientCompaniesCompanyIdPassesGet convention):

  • apiClientCompaniesCompanyIdPassesGet(companyId)List<PassClientDto>
  • apiClientCompaniesCompanyIdPassesMineGet(companyId, status?)List<MyPassDto>
  • apiClientCompaniesCompanyIdPassesActivitiesActivityIdMyEntitlementsGet(companyId, activityId)List<MyEntitlementForActivityDto>
  • apiClientCompaniesCompanyIdPassesPurchasePost(companyId, body: PurchasePassDto)PurchasePassResponse ({ customerPass: MyPassDto, payment?: { id, redirectUrl } }payment only present for LIQPAY; see audit fix #8 for the backend normalization that makes this shape consistent across all three payment-method branches)
  • apiClientCompaniesCompanyIdPassesPassIdCancelPost(companyId, passId)MyPassDto

No hand-written client code in this package — only generated.

New files:

  • lib/src/passes/passes_repository.dart — thin facade over Api.invoke(...) for the five client endpoints. Returns parsed DTOs.
  • lib/src/passes/passes_state.dartChangeNotifier (matching the existing CheckoutService style) holding catalogue list and my-passes list with status filter. No Riverpod — confirmed by reading existing tktspace_core.dart which only exports a single StatusBarService; the project uses provider + ChangeNotifier.
  • Export them from packages/core/lib/tktspace_core.dart.

passes_state.dart exposes:

  • loadCatalogue(companyId), catalogue getter, loadingCatalogue flag, error.
  • loadMine(companyId, {String? status}), mine getter, mineFilter setter (broadcasts notifyListeners).
  • cancel(companyId, customerPassId) → reissues loadMine after success.
  • entitlementsForActivity(companyId, activityId) — passthrough to repo, used by booking summary step.

The package hosts TWO distinct flows that share UI primitives but never share domain logic. Below is the file-by-file breakdown of which files are NEW for each flow vs which files are EDITED, and why the spec’s “no shared business logic” constraint (lines 47-54 of the spec) still holds.

FlowAC coverageItem configConfirm handlerAPI endpoint hit
Pass purchaseAC-12, AC-13, AC-14, AC-18PassPurchaseConfigPassCheckoutService.confirmPOST /companies/{cid}/passes/purchase
Booking with passAC-16, AC-17existing BookingConfig (extended optional entitlementId)existing booking confirm handlerPOST /companies/{cid}/bookings (existing)

The two flows touch summary_step.dart differently:

  • Pass purchase flow (AC-12/13/14/18): REUSES lib/src/steps/summary_step.dart UNCHANGED. The pass-checkout route composes the existing summary step’s payment-method picker, LiqPay redirect handler, and pending-state primitives. No edits to summary_step.dart for the purchase flow.
  • Booking-with-pass flow (AC-16/17): EDITS lib/src/steps/summary_step.dart to compose a NEW UsePassPicker widget when companyId AND activityId are present on the booking config (always true on the booking path; never true on the pass-purchase path). The widget renders zero children for users with no eligible entitlements (graceful no-op).

Why this does NOT violate the spec’s “no shared business logic” guarantee

Section titled “Why this does NOT violate the spec’s “no shared business logic” guarantee”

The spec (lines 47-54) requires that pass-purchase and booking-purchase be decoupled at the business-logic level. The booking-with-pass integration:

  1. Calls a READ-ONLY query GET /companies/{cid}/passes/activities/{aid}/my-entitlements to populate the picker. No purchase logic is invoked from the booking path.
  2. Returns a single entitlementId: string? that the booking-create call optionally forwards.
  3. Mutates pass state SERVER-SIDE only — the existing backend validateAndUseEntitlement call inside BookingsClientService.create is the authority. The mobile PassCheckoutService state machine is NOT touched by the booking path; the booking path also does not import pass_checkout_service.dart.
  4. UsePassPicker lives in packages/ui (a presentation package), not in packages/checkout. It receives (companyId, activityId), queries PassesRepository (in packages/core), and emits an entitlementId?. It contains no purchase, no payment-method picking, no state machine.

Concretely: the booking flow never instantiates PassCheckoutService, never reads PassPurchaseConfig, never calls POST /passes/purchase. The pass-purchase flow never instantiates BookingConfig, never calls POST /bookings. The only shared code is summary_step.dart’s payment-method picker (the picker has no booking-vs-pass branches; it operates on a paymentMethod string).

NEW under packages/checkout/lib/src/:

  • pass_purchase/pass_purchase_config.dart — declares PassPurchaseConfig (immutable value type).
    class PassPurchaseConfig {
    final String passId;
    final String priceVariantId; // priceId on backend
    final String companyId;
    final List<String> allowedPaymentMethods; // ['WALLET','LIQPAY','MANUAL']
    final String? successDeepLink; // e.g. com.fitspace.client.app://payments/success?passId=…
    final String? failureDeepLink;
    }
  • pass_purchase/pass_checkout_service.dart — declares PassCheckoutService extends ChangeNotifier. Holds config, selectedPaymentMethod, exposes setConfig, setPaymentMethod, reset, confirm. The confirm method calls the generated apiClientCompaniesCompanyIdPassesPurchasePost with resultUrl = config.successDeepLink (do not introduce a new successDeepLink field on the API surface).
  • pass_purchase/pass_purchase_steps.dart — composes the pass-specific summary header (template name, price, included activities) and the payment-method picker (reused from summary_step.dart, no edits).
  • pass_checkout_page.dart — top-level route widget that wires PassCheckoutService and the steps above.
  • pending_pass_payment_page.dart — shown after returning to app from gateway (deep-link OR user manual back). Reads customerPassId and re-fetches via listMine. No polling. Resolves when the pass status flips from AWAITING_PAYMENT to ACTIVE (webhook-driven on backend) — re-fetch is single-shot per UI event (didChangeAppLifecycleState → resumed, manual pull-to-refresh, or initial deep-link landing).

EDITED for AC-16/17 ONLY (booking-with-pass integration):

  • lib/src/steps/summary_step.dart — composes the new UsePassPicker widget when the active booking config exposes both companyId and activityId. The widget reports an entitlementId? upward via callback. NO edits to the payment-method picker, the LiqPay handler, or the pending-state code paths used by pass purchase.
  • lib/src/models/booking_config.dart (or wherever the existing booking item is declared) — adds an OPTIONAL entitlementId: String? field forwarded to the existing booking-create call.

NEW shared widget (placement decision — packages/ui):

  • packages/ui/lib/src/components/use_pass_picker.dart — the picker widget. Decision: place in packages/ui because it composes the already-shared entitlement_chip.dart (declared elsewhere in this ADR) and is reusable by any future flow that needs to ask the customer “which entitlement do you want to use here?”. The widget calls PassesRepository.entitlementsForActivity (declared in packages/core) and emits a single entitlementId?. It has no state machine.

Exports via packages/checkout/lib/tktspace_checkout.dart:

export 'src/pass_purchase/pass_purchase_config.dart';
export 'src/pass_purchase/pass_checkout_service.dart';
export 'src/pass_checkout_page.dart';
export 'src/pending_pass_payment_page.dart';

Four new shared widgets (matching the style of app_button.dart, app_modal_sheet.dart already present):

  • lib/src/components/pass_card.dart — card layout for catalogue + my-passes list rows (image-less per spec; title, price snapshot, validUntil, sessionsRemaining summary).
  • lib/src/components/pass_status_badge.dart — pill widget colored per CustomerPassStatusEnum value: AWAITING_PAYMENT (amber), PENDING (grey), ACTIVE (green), PAUSED (blue), EXPIRED (red-muted), CANCELLED (red).
  • lib/src/components/entitlement_chip.dart — small chip showing activityName · sessionsRemaining/sessionsLimit (renders if sessionsLimit == null).
  • lib/src/components/use_pass_picker.dart — picker used by the booking summary step (AC-16/17). Receives (companyId, activityId), queries PassesRepository.entitlementsForActivity (in packages/core), composes entitlement_chip.dart rows, and emits a single entitlementId? upward via callback. Contains no purchase or state machine — read-only fetch + selection.

Re-export via lib/tktspace_ui.dart. Strings are i18n keys (deferred from spec — UA hardcoded in scheduler stays out of scope; new widgets use existing easy_localization keys under passes#…).

Not touched in this ticket (per spec out-of-scope).

Not touched (per spec out-of-scope).

All paths below are written relative to the contract’s servers[].url (which is …/api/business or …/api/client). Backend Nest controllers prepend the surface-mount-path automatically.

Business surface — contracts/business.openapi.yaml

Section titled “Business surface — contracts/business.openapi.yaml”
MethodPathRequest bodyResponse 2xxPermissionBackend handler
GET/passesPaginatedPassResponseDtoMANAGE_ACTIVITIESPassesAdminController.findAll
GET/passes/{id}PassResponseDtoMANAGE_ACTIVITIESPassesAdminController.findOne
POST/passesCreatePassDto201 PassResponseDtoMANAGE_ACTIVITIESPassesAdminController.create
PATCH/passes/{id}UpdatePassDtoPassResponseDtoMANAGE_ACTIVITIESPassesAdminController.update
POST/passes/{id}/togglePassResponseDtoMANAGE_ACTIVITIESPassesAdminController.toggle
GET/customers/{customerId}/passes?status=&limit=&page=PaginatedCustomerPassResponseDtoREAD_CUSTOMERSCustomerPassesAdminController.findAllstatus typed as CustomerPassStatus enum (audit fix #3)
POST/customers/{customerId}/passesIssuePassDto201 CustomerPassResponseDtoMANAGE_CUSTOMERSCustomerPassesAdminController.issue
POST/customers/{customerId}/passes/{customerPassId}/pauseCustomerPassResponseDtoMANAGE_CUSTOMERSCustomerPassesAdminController.pause
POST/customers/{customerId}/passes/{customerPassId}/resumeCustomerPassResponseDtoMANAGE_CUSTOMERSCustomerPassesAdminController.resume
PATCH/customers/{customerId}/passes/{customerPassId}/adjustAdjustCustomerPassDtoCustomerPassResponseDtoMANAGE_CUSTOMERSCustomerPassesAdminController.adjust
DELETE/customers/{customerId}/passes/{customerPassId}CustomerPassResponseDtoMANAGE_CUSTOMERSCustomerPassesAdminController.cancel

All require BearerAuth AND company-id API key (Swagger has both).

Client surface — contracts/client.openapi.yaml

Section titled “Client surface — contracts/client.openapi.yaml”
MethodPathRequest bodyResponse 2xxBackend handler
GET/companies/{companyId}/passesPassClientDto[]PassesClientController.listAvailable
GET/companies/{companyId}/passes/mine?onlyActive=MyPassDto[]PassesClientController.listMineonlyActive boolean filter (audit fix #5)
GET/companies/{companyId}/passes/activities/{activityId}/my-entitlementsMyEntitlementForActivityDto[]PassesClientController.getMyEntitlements
POST/companies/{companyId}/passes/purchasePurchasePassDto201 MyPassDto (with optional payment block for LIQPAY)PassesClientController.purchase
POST/companies/{companyId}/passes/{passId}/cancelMyPassDtoPassesClientController.cancelMyPass

All require BearerAuth only.

Surface impact and field-visibility justification

Section titled “Surface impact and field-visibility justification”

Both surfaces touch the same domain entities but expose intentionally different field sets:

  • PassClientDto (client) vs PassResponseDto (business): The client form omits operator-only fields: companyId, notifySessionsRemaining, expiryNotifyDays, isActive, createdAt, updatedAt. The client only sees what is needed to render a buy-page. cancelRefundPolicy IS surfaced on PassClientDto (small enum, not PII) so the mobile UI can render the refund rule on the cancel-confirmation sheet (“You will receive: Full refund / Proportional refund / No refund”) before the user taps Cancel. This serves AC-18: the customer sees the policy upfront rather than learning it post-cancel via the resulting refund amount.
  • MyEntitlementDto (client mine) vs CustomerEntitlementResponseDto (business): Client form omits isActive (never relevant — the consumer only sees their active entitlements anyway). MyEntitlementDto.id is the entitlement’s UUID, which is forwarded as entitlementId when booking with a pass.
  • MyPassDto (client) vs CustomerPassResponseDto (business): Client form omits customerId (caller IS the customer), paymentMethod (not customer-facing — the wallet/LiqPay distinction is shown via balance changes elsewhere), pausedAt (not surfaced — the customer only sees status: PAUSED), createdAt, updatedAt. Adds passName (denormalized so the client doesn’t need to call /companies/{id}/passes to render a row).
  • Audit fix #4 — list response shape: The new PaginatedPassResponseDto.items[] is PassResponseDto including entitlements and prices arrays. The client side does NOT need this enriched form because listAvailablePasses already returns the full shape (PassClientDto with entitlements + prices baked in). So fix #4 is a business-surface-only change.
  • CustomerPassesListQueryDto.status (admin) and PassesClientController.listMine.onlyActive (client): The two surfaces deliberately diverge. Admin (audit fix #3) gets a typed status: CustomerPassStatus query param so operators can filter the customer-detail passes tab by any specific lifecycle state. Client (audit fix #5) gets a SIMPLER onlyActive: boolean query param — when true, returns only passes with status ACTIVE or PAUSED (the “currently usable” set); when omitted/false, returns everything. End-users only ever toggle between “currently useful to me” and “all my history” per AC-15, so the boolean expresses the contract intent more honestly than overloading the enum (and avoids inventing a synthetic ACTIVE_OR_PAUSED value or array-of-status semantics). The shared CustomerPassStatus schema is still duplicated per surface per the project rule “never share types between surfaces” (the duplication is tolerated because the enum values are policy data shared across the platform; if they ever drift, that is a backend schema change and both contracts get patched).
Audit fixWhat changesMigration required?
#1UpdatePassDto: remove (i.e. don’t add) isActive field — already absent in current code; keep absent. NestJS DTO only.No
#2AdjustCustomerPassDto: replace single addSessions (line 200-205 of passes-admin.dto.ts) with explicit addSessions + subtractSessions pair, mutually exclusive via custom class-validator constraint (@ValidateIf or a custom @MutuallyExclusive(['addSessions','subtractSessions']) decorator placed in libs/shared/common/). Service CustomerPassesService.adjust (lines 187-224 of customer-passes.service.ts) updated: handle addSessions (decrement sessionsUsed, clamped to 0) and subtractSessions (increment sessionsUsed, clamped to ≤ sessionsLimit if non-null).No
#3CustomerPassesListQueryDto.status: replace IsString with IsEnum(CustomerPassStatusEnum). Need to expose the enum as a TS enum (currently a Drizzle pgEnum) — easiest: re-export the enum values as a TS const-assert tuple in the DTO file, or introduce CustomerPassStatus enum in libs/features/passes/src/lib/dto/passes-admin.dto.ts (mirroring the CancelRefundPolicy enum pattern in the same file).No
#4PassesService.findAll (lines 19-41): join entitlements + prices into the response. Implementation: after fetching the page of passes, run two grouped queries (WHERE passId IN (...)) and zip into items. Avoid a per-pass loop.No
#5PassesClientController.listMine (lines 37-42): add optional @Query('onlyActive') onlyActive?: boolean (use ParseBoolPipe); thread through to passesClientService.listMyPasses(userId, companyId, { onlyActive? }). When onlyActive === true, the service WHERE-clause filters status IN ('ACTIVE','PAUSED'). When omitted or false, no status filter is applied.No
#6PassesSchedulerService.handleLowSessionsNotifications (lines 45-122 of passes-scheduler.service.ts): replace per-pass entitlement fetch (lines 78-89) with a single SELECT that joins customer_pass + pass + company_customer + customer_entitlement and returns MIN(sessionsLimit - sessionsUsed) FILTER (WHERE sessionsLimit IS NOT NULL) per pass via GROUP BY customer_pass_id. Use Drizzle SQL fragments for the aggregate. Also edit passes.schema.ts to add a non-unique index on customer_entitlement.customer_pass_id: (t) => ({ customerPassIdIdx: index('customer_entitlement_customer_pass_id_idx').on(t.customerPassId) }). Rationale: the scheduler now scans customer_entitlement filtered by customerPassId for every active pass with notifySessionsRemaining set; without an index this is a sequential scan O(N × M). Tradeoff: write amplification on every pass issuance and every booking-consume tx; acceptable given write rate is dominated by booking inserts elsewhere.YES — single drizzle-kit generate run picks up this index addition.
#7PassesClientService.purchasePass (line 117): change 'PENDING' to 'ACTIVE' in the WALLET branch when priceAmount > 0 (debit succeeded) AND ALSO set activatedAt = now(), validUntil = now() + validityDays * 1day. Free WALLET passes (priceAmount === 0) — also become ACTIVE since no debit failed. The PENDING status is preserved ONLY for the MANUAL branch (line 122) where the operator confirms cash physically and the customer hasn’t started using it yet. The LIQPAY branch (line 143) keeps 'AWAITING_PAYMENT' unchanged — the webhook flips it to ACTIVE.No
#8 (NEW)Normalize PassesClientService.purchase return shape across all three payment-method branches. Today: WALLET (line 117) and MANUAL (line 122) return { customerPass: { ...row, passName }, entitlements: [...] } (entitlements SIBLING of customerPass), while LIQPAY (line 156) returns { customerPass: { ...row, passName, entitlements }, payment } (entitlements NESTED in customerPass). Three branches, three shapes — generated TS/Dart clients break. Fix: change createPassWithEntitlements (lines 198-220) to return { customerPass: { ...customerPass, passName, entitlements } } (entitlements always nested). Update the WALLET and MANUAL return statements at lines 117 and 122 to forward that envelope unchanged. After the fix every branch returns { customerPass: MyPassDto, payment? } with entitlements as a REQUIRED nested field of MyPassDto; payment is present only for LIQPAY/MONOPAY. This is the contract shape already declared by PurchasePassResponse in client.openapi.yaml. Required for AC-13 (success screen renders entitlements from the response).No

Backend audit findings (added during architecture)

Section titled “Backend audit findings (added during architecture)”

The following fix was discovered during architecture review and is NOT in the (already-approved) spec. It is recorded here so backend-dev picks it up alongside the seven spec-listed fixes.

  • Fix #8 — Normalize PassesClientService.purchase return shape. Read tktspace-backend/libs/features/passes/src/lib/services/passes-client.service.ts:

    • WALLET branch (line 117) returns createPassWithEntitlements(...) whose result is { customerPass: { ...customerPassRow, passName }, entitlements: [...] }. entitlements is a SIBLING of customerPass.
    • MANUAL branch (line 122) returns the same shape — entitlements SIBLING.
    • LIQPAY branch (line 156) destructures the helper result and re-wraps it as { customerPass: { ...customerPass, passName: pass.name, entitlements }, payment }entitlements NESTED in customerPass.

    Three branches, three shapes. Generated TS (ng-openapi-gen) and Dart (chopper_generator) clients cannot deserialize this; tests that pass against PurchasePassResponse would fail against the actual backend.

    Required normalization: every branch returns

    {
    customerPass: MyPassDto, // entitlements REQUIRED nested field of MyPassDto
    payment?: PaymentInitiationDto // present only for LIQPAY (and future MONOPAY)
    }

    Implementation:

    • Edit createPassWithEntitlements (lines 198-220) so the returned customerPass already contains entitlements. Change the final return { customerPass: { ...customerPass, passName: pass.name }, entitlements }; to return { customerPass: { ...customerPass, passName: pass.name, entitlements } };.
    • WALLET (line 117) and MANUAL (line 122): return this.createPassWithEntitlements(...) directly — no change to call sites once the helper returns the normalized shape.
    • LIQPAY (lines 142-156): const { customerPass } = await this.createPassWithEntitlements(...); then return { customerPass, payment }. (Drop the local re-flattening.)

    Contract verification: PurchasePassResponse in client.openapi.yaml is { customerPass: MyPassDto, payment?: { id, redirectUrl } }, and MyPassDto already lists entitlements in its required array. After fix #8 the contract is correct as-is — no contract patch needed.

    This fix is required for AC-13 (the WALLET success screen must render entitlements from the response payload to display per-activity sessions remaining).

Edit libs/shared/data-access-db/src/lib/schema/passes.schema.ts — replace the customerEntitlements table declaration to include an index(...) callback. Drizzle syntax preview:

import { index } from 'drizzle-orm/pg-core';
// ...
export const customerEntitlements = passesSchema.table('customer_entitlement', {
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
customerPassId: uuid('customer_pass_id').notNull().references(() => customerPasses.id, { onDelete: 'restrict' }),
// … (other columns unchanged)
}, (t) => ({
customerPassIdIdx: index('customer_entitlement_customer_pass_id_idx').on(t.customerPassId),
}));

All changes land inside the existing libs/features/passes/ library — no new Nx app or library is created.

  • libs/features/passes/src/lib/dto/passes-admin.dto.ts — patch DTOs for fixes #2, #3.
  • libs/features/passes/src/lib/dto/passes-client.dto.ts — no shape changes; only the controller gains a status query param (#5).
  • libs/features/passes/src/lib/services/passes.service.ts — patch findAll for fix #4.
  • libs/features/passes/src/lib/services/passes-client.service.ts — patch line 117 for fix #7; thread status through listMyPasses; patch createPassWithEntitlements (lines 198-220) and the WALLET/MANUAL/LIQPAY return statements (lines 117, 122, 142-156) for fix #8.
  • libs/features/passes/src/lib/services/customer-passes.service.ts — patch adjust for fix #2.
  • libs/features/passes/src/lib/services/passes-scheduler.service.ts — refactor handleLowSessionsNotifications for fix #6.
  • libs/features/passes/src/lib/controllers/passes-client.controller.ts — add @Query('status') status?: CustomerPassStatusEnum.

A new shared utility may be added: a @MutuallyExclusive(...) class-validator decorator in libs/shared/common/src/decorators/, used by fix #2. If preferred, the same constraint can be expressed inline with @ValidateIf((o) => o.subtractSessions === undefined) on addSessions and the symmetric form on subtractSessions — no shared utility needed. Choice deferred to dev phase.

No changes to libs/shared/data-access-db/src/lib/schema/companies.schema.ts, activities.schema.ts, etc.

  • Routes touched: client/src/app/features/dashboard/activities/activities.route.ts.
  • New folders to scaffold (mirror the locations/customers patterns):
    • client/src/app/features/dashboard/activities/pages/passes-list/
    • client/src/app/features/dashboard/activities/pages/pass-form/
    • client/src/app/features/dashboard/customers/modals/issue-pass/
    • client/src/app/features/dashboard/customers/modals/adjust-customer-pass/
  • Sidebar: client/src/app/core/services/menu.service.ts add Passes sub-item.
  • Customer detail page edits: client/src/app/features/dashboard/customers/pages/customer-detail/customer-detail.page.{ts,html} — add a Passes section.
  • Taiga UI primitives to use: tuiTable (paginated list), TuiInputModule (form fields), TuiButtonModule, TuiBadgeModule (active/inactive badge), TuiSelectModule (price-variant picker, payment-method picker), TuiDialogService (modals), TuiNotificationModule (success/error toasts).
  • API client: regenerate via npm run generate:api.
  • New pages under apps/gym_app/lib/pages/passes/ and apps/gym_app/lib/pages/payments/ (success/failure landing pages — see Deep-linking).
  • Router: edit apps/gym_app/lib/router/app_router.dart to add the routes listed in “Surface-by-surface breakdown / apps/gym_app”.
  • Booking integration touch: edit packages/checkout/lib/src/steps/summary_step.dart to compose the new UsePassPicker widget (declared in packages/ui) when companyId AND activityId are present on the booking config, and edit packages/checkout/lib/src/models/booking_config.dart to thread the optional entitlementId through the booking-create call. This is a non-breaking, non-pass-coupling addition — bookings without a pass continue to work; the picker renders zero children when the user has no eligible entitlements. See the packages/checkout section above for the full justification that this UI inclusion does not violate the spec’s “no shared business logic” guarantee.

Not touched.

Not touched.

Mobile architecture (recap of package responsibilities)

Section titled “Mobile architecture (recap of package responsibilities)”
PackageTouched?Files added
apiYes (regen)All under lib/src/... — fully auto-generated by melos run generate:api after melos run sync:spec pulls the updated client.openapi.yaml-derived swagger.
coreYeslib/src/passes/passes_repository.dart, lib/src/passes/passes_state.dart; export from tktspace_core.dart.
checkoutYesNEW for pass purchase: lib/src/pass_purchase/pass_purchase_config.dart, lib/src/pass_purchase/pass_checkout_service.dart, lib/src/pass_purchase/pass_purchase_steps.dart, lib/src/pass_checkout_page.dart, lib/src/pending_pass_payment_page.dart. EDITED for AC-16/17 booking-with-pass: lib/src/steps/summary_step.dart (composes UsePassPicker), lib/src/models/booking_config.dart (adds optional entitlementId). Export new pass files from tktspace_checkout.dart.
uiYeslib/src/components/pass_card.dart, lib/src/components/pass_status_badge.dart, lib/src/components/entitlement_chip.dart, lib/src/components/use_pass_picker.dart; export from tktspace_ui.dart.
auth, i18n, notifications, profileNo

tickets_app is not touched. The pass-purchase flow being implemented inside packages/checkout (rather than gym_app-only code) means a future ticket can reuse it for tickets_app if needed.

Confirmed by reading:

  • apps/gym_app/android/app/src/main/AndroidManifest.xml lines 27-32 — registers an intent-filter for scheme com.fitspace.client.app (the bundle id; the value is hard-coded today, but is generated per-brand by the add:brand melos script).
  • apps/gym_app/ios/Runner/Info.plistCFBundleURLTypes registers $(PRODUCT_BUNDLE_IDENTIFIER) as a URL scheme.

So both platforms already accept incoming URLs of the form <bundle-id>://<host>/<path>?<query>.

The scheme is <bundle-id> (e.g. com.fitspace.client.app for the tktspace flavor of gym_app). The brand config (brands/<brand>.dart) does not yet expose the deep-link host; introduce Brand.deepLinkHost (a string like payments) shared across brands, and emit URLs of form:

  • success: com.fitspace.client.app://payments/success?passId=<customerPassId>
  • failure: com.fitspace.client.app://payments/failure?passId=<customerPassId>

PassPurchaseConfig.successDeepLink / failureDeepLink are passed in by gym_app at config-construction time. They are sent to the backend as PurchasePassDto.resultUrl (already supported on the backend — line 22-26 of passes-client.dto.ts). LiqPay redirects to this URL; the OS opens the app and routes via go_router to /payments/success?passId=….

The deep-link is the SOLE UX resolution path (per spec AC-14 explicit non-goals). The race to consider:

  1. User pays in LiqPay → backend webhook fires asynchronously and flips customer_pass.status from AWAITING_PAYMENT to ACTIVE.
  2. LiqPay redirects user back to the app via deep-link; the app lands on /payments/success.

Cases:

  • Webhook fires BEFORE deep-link: /payments/success queries listMine and finds the pass already ACTIVE. Show success.
  • Deep-link fires BEFORE webhook: /payments/success queries listMine and finds the pass still AWAITING_PAYMENT. The page must NOT show a permanent failure — it shows a “confirming…” state and re-fetches listMine once on a debounced user-driven refresh (pull-to-refresh) or on WidgetsBindingObserver.didChangeAppLifecycleState → resumed. This is NOT polling — it is single-shot per UI event. If the customer leaves the screen, navigation pushes to /my-passes and the same status applies — the next list fetch picks up the now-ACTIVE state.
    • Worst-case wording for AC-14 UX (explicit): if the user lands on the success screen before the webhook fires, the screen shows “Confirming payment…” indefinitely until the user pulls to refresh, leaves the screen, or backgrounds and reforegrounds the app. The backend reconciliation cron is the terminal-state safety net — it transitions stuck AWAITING_PAYMENT rows to a terminal status, so the user’s next list fetch always resolves the screen. UX must NOT show a misleading “Payment failed” banner during the confirming state; copy must be neutral (“Confirming payment with the gateway, this can take a few seconds”).
  • User cancels in LiqPay: gateway redirects to failureDeepLink. App lands on /payments/failure. Backend webhook will eventually mark the payment failed (existing wallet/booking flow). The customer-pass row stays AWAITING_PAYMENT until the backend reconciliation cron transitions it to CANCELLED (existing behavior; no new code).
  • Cold launch via deep-link: go_router’s initialLocation is /home/main, but the deep-link is honored via the standard plugin handler. app_router.dart takes care because the route is declared.
  • Deep-link is dropped (e.g. user kills app): customer opens /my-passes later; the list shows the pass in whatever status the webhook produced. No data loss.

The spec mentions Monopay (Monobank) but the backend code only implements LIQPAY as the online gateway. Treat Monopay as a future addition; for this ticket only WALLET, LIQPAY, and MANUAL are wired.

┌────────────────────────┐
(LIQPAY) │ AWAITING_PAYMENT │ — webhook → ACTIVE
└────────────────────────┘ │
┌──────────┐ first-use ┌──────────┐ expiry ┌─────────┐
(WALLET pre-#7) │ PENDING │ ───────────▶ │ ACTIVE │ ───────▶ │ EXPIRED │
└──────────┘ └──────────┘ └─────────┘
│ ▲
(MANUAL still creates PENDING) │ pause│ resume
▼ │
┌──────────────┐
│ PAUSED │
└──────────────┘
cancel (any non-CANCELLED) → CANCELLED

Audit fix #7 changes the WALLET branch to skip PENDING and create directly in ACTIVE (with activatedAt = now(), validUntil = now() + validityDays). MANUAL keeps PENDING (reasonable — operator wrote down cash; customer hasn’t started yet).

Adjust customer pass — request validation

Section titled “Adjust customer pass — request validation”

Mutually exclusive: the request body MAY NOT contain both addSessions and subtractSessions. customerEntitlementId is REQUIRED when either of those is present. extendDays is independent and MAY be combined with one of the session adjustments. Server returns 400 if both addSessions and subtractSessions are present.

This table is the entry point for Phase B test-writers. One row per AC, plus one row per audit fix. Test files paths are relative to the affected repo root.

AC / FixSurfaceTest frameworkTest file path
AC-0tktspace-businessKarma unit + Cypress E2Eclient/src/app/core/services/menu.service.spec.ts; cypress/e2e/passes/app-shell.cy.ts
AC-1tktspace-businessKarma unit + Cypress E2Eclient/src/app/features/dashboard/activities/pages/passes-list/passes-list.page.spec.ts; cypress/e2e/passes/passes-list.cy.ts
AC-2tktspace-businessKarma unit + Cypress E2Eclient/src/app/features/dashboard/activities/pages/pass-form/pass-form.page.spec.ts (create); cypress/e2e/passes/pass-create.cy.ts
AC-3tktspace-businessKarma unit + Cypress E2Epass-form.page.spec.ts (edit branch); cypress/e2e/passes/pass-edit.cy.ts
AC-4tktspace-businessCypress E2Ecypress/e2e/passes/pass-toggle.cy.ts
AC-5tktspace-businessKarma unitpasses-list.page.spec.ts (filter branch)
AC-6tktspace-businessKarma unit + Cypress E2Eclient/src/app/features/dashboard/customers/pages/customer-detail/customer-detail.page.spec.ts; cypress/e2e/passes/customer-passes-list.cy.ts
AC-7tktspace-businessKarma unit + Cypress E2Eclient/src/app/features/dashboard/customers/modals/issue-pass/issue-pass.modal.spec.ts; cypress/e2e/passes/issue-pass.cy.ts
AC-8tktspace-businessCypress E2Ecypress/e2e/passes/customer-pass-pause.cy.ts
AC-9tktspace-businessCypress E2Ecypress/e2e/passes/customer-pass-resume.cy.ts
AC-10a–ctktspace-businessKarma unit + Cypress E2Eclient/src/app/features/dashboard/customers/modals/adjust-customer-pass/adjust-customer-pass.modal.spec.ts; cypress/e2e/passes/customer-pass-adjust.cy.ts
AC-10dbackendJest unit + e2elibs/features/passes/src/lib/dto/passes-admin.dto.spec.ts (mutual-exclusion validator); apps/api-e2e/src/passes/customer-passes.e2e-spec.ts (HTTP 400 case)
AC-11tktspace-businessCypress E2Ecypress/e2e/passes/customer-pass-cancel.cy.ts
AC-12gym_appFlutter widget + integrationapps/gym_app/test/pages/passes/passes_catalogue_page_test.dart; apps/gym_app/integration_test/passes_catalogue_test.dart
AC-13gym_appFlutter integrationapps/gym_app/integration_test/passes_purchase_wallet_test.dart
AC-14gym_appFlutter integration + manual smokeapps/gym_app/integration_test/passes_purchase_liqpay_test.dart (mocked redirect); manual deep-link smoke documented in docs/manual-tests/passes-deeplink.md
AC-15gym_appFlutter widget + integrationapps/gym_app/test/pages/passes/my_passes_page_test.dart; apps/gym_app/integration_test/my_passes_test.dart
AC-16gym_appFlutter widgetpackages/ui/test/components/use_pass_picker_test.dart; packages/checkout/test/steps/summary_step_test.dart (renders picker when companyId+activityId present)
AC-17gym_appFlutter integrationapps/gym_app/integration_test/booking_with_pass_test.dart
AC-18gym_appFlutter widget + integrationapps/gym_app/test/pages/passes/cancel_pass_sheet_test.dart; apps/gym_app/integration_test/passes_cancel_test.dart
AC-19backendJest unitlibs/features/passes/src/lib/services/passes-scheduler.service.spec.ts — mock db.select and assert call count is constant (1 join query) regardless of pass count
Fix #1backendJest unitlibs/features/passes/src/lib/dto/passes-admin.dto.spec.tsUpdatePassDto rejects/strips isActive
Fix #2backendJest unit + e2epasses-admin.dto.spec.ts (mutex constraint); apps/api-e2e/src/passes/customer-passes.e2e-spec.ts (adjust endpoint)
Fix #3backendJest unit + e2epasses-admin.dto.spec.ts (@IsEnum); apps/api-e2e/src/passes/customer-passes.e2e-spec.ts (status filter rejects invalid values)
Fix #4backendJest unit + e2elibs/features/passes/src/lib/services/passes.service.spec.ts (findAll returns entitlements + prices); apps/api-e2e/src/passes/passes-admin.e2e-spec.ts
Fix #5backendJest unit + e2elibs/features/passes/src/lib/services/passes-client.service.spec.ts (status threading); apps/api-e2e/src/passes/passes-client.e2e-spec.ts
Fix #6backendJest unitpasses-scheduler.service.spec.ts (single-query refactor — same file as AC-19)
Fix #7backendJest unit + e2epasses-client.service.spec.ts (WALLET branch sets ACTIVE + activatedAt + validUntil); apps/api-e2e/src/passes/passes-client.e2e-spec.ts
Fix #8backendJest unit + e2epasses-client.service.spec.ts (all three branches return identically-shaped envelope with entitlements nested in customerPass); apps/api-e2e/src/passes/passes-client.e2e-spec.ts (response shape contract test against PurchasePassResponse)
  • Deep-link race condition (above) — mitigated by the “confirming…” UX pattern and the single-shot refetch on resumed. Failure mode: user sees confirming screen indefinitely if the gateway never confirms; the existing backend reconciliation cron transitions stuck AWAITING_PAYMENT rows to a terminal state, so the user’s next list fetch resolves it.
  • Refund-policy edge case on cancel: calculateRefundAmount (lines 259-295 of customer-passes.service.ts) handles PROPORTIONAL only when ALL entitlements are limited; if any is unlimited it returns 0. This is intentional but could surprise operators. Document in the issue UI tooltip on the cancel button. NOT a code change in this ticket.
  • RBAC propagation: business panel must hide actions whose backend permission the user lacks. Mitigation: reuse existing hasPermission utility (already used for member management).
  • Idempotency on purchase (V1 acceptable risk): the backend does NOT enforce idempotency on POST /passes/purchase. A double-tap on Buy could in principle create two customer-pass rows. V1 mitigation: the UI disables the Buy button from the moment the request is dispatched until the response (success OR error) settles. The Pass purchase service holds an in-flight flag (isPurchasing: bool) that drives the button’s disabled state. The dev-phase test plan explicitly includes a widget test that simulates rapid double-taps and asserts only one network call is made. Future ticket (out of scope here): add a client-supplied Idempotency-Key header to POST /passes/purchase and enforce backend-side de-duplication; this is the proper fix but is unnecessary for V1 launch given the UI button-disable mitigation.
  • Contract drift between admin and client surfaces: the same enum (CustomerPassStatusEnum) is duplicated in both YAMLs. If a future ticket adds a status value, both contracts MUST be patched in lockstep. Mitigation: a checklist item in the spec template referencing this ADR.
  • Index write amplification (#6): low risk — booking-consume uses UPDATE customer_entitlement WHERE id = ?, and the new index is on customer_pass_id. The update tx pays a tiny B-tree maintenance cost; absolute throughput unaffected at current scale.
  • Migration ordering with code change: the drizzle-kit generate migration only adds a B-tree index. Drizzle emits plain CREATE INDEX (no CONCURRENTLY) which acquires a SHARE lock on customer_entitlement for the duration of the build — blocking writes, not reads. Expected duration at current scale (tens of thousands of rows): well sub-second; booking-consume writes during the window queue and resolve before request timeout. Guideline: switch to CREATE INDEX CONCURRENTLY (run manually outside the migration runner, then mark the Drizzle migration applied) ONLY if production customer_entitlement row count exceeds ~500k at deploy time. The migration-safety-check skill MUST be run on the generated SQL in libs/shared/data-access-db/migrations/ before the backend MR is merged. The scheduler refactor depends on the index being present; standard merge-then-deploy ordering ensures schema is updated before the scheduler runs the new query.

No feature flag. The two contracts are published on the workflow-repo MR; consumers regenerate clients and ship in their own MRs.

Contract version bump: both contracts/business.openapi.yaml and contracts/client.openapi.yaml move from the previous empty stubs to info.version: 0.2.0 in this MR. (Both files already carry 0.2.0 in the patched stubs in this branch.) Future passes-related contract changes increment the minor or patch component per semver discipline; backend implementation MRs reference the contract version they target.

Ordering:

  1. _workflow MR (this ADR + contract patches at 0.2.0) lands on main.
  2. tktspace-backend MR — applies all 8 audit fixes (the seven from the spec plus #8 from this ADR’s “Backend audit findings (added during architecture)” subsection), generates the migration (single drizzle-kit generate run), runs migration-safety-check skill on the produced SQL. Deploys to staging.
  3. In parallel: tktspace-business MR — npm run generate:api then implements the new pages/modals.
  4. In parallel: tktspace-mobile-app MR — melos run sync:spec && melos run generate:api, implements the four touched packages and the new gym_app pages.

Backwards-compat:

  • AdjustCustomerPassDto shape change (addSessions: positive int + maybe subtractSessions: positive int) is a breaking change for any existing client of /api/business/customers/{id}/passes/{cpid}/adjust. There are no production clients today (the panel does not yet have this UI). Safe.
  • The WALLET → ACTIVE behavior change is observable via API responses but not contract-breaking — the schema still says status: string (or now an enum). External integrations are nil. Safe.
  • The onlyActive query param on client listMine is additive — old callers omit it (or pass false) and get unfiltered results (previous behavior). Safe.
  • The findAll response now includes entitlements and prices. Schema change adds two array fields — additive on the response. Safe.
  • Fix #8 (purchase return shape normalization) reshapes the POST /companies/{cid}/passes/purchase response. The previous WALLET/MANUAL shape (entitlements sibling of customerPass) is replaced with the LIQPAY shape (entitlements nested in customerPass) for all branches. There are no production clients of this endpoint yet (mobile apps have no pass UI today). Safe.

No backfill needed (no historical data is touched).

STATUS: READY_FOR_REVIEW