ADR: Implement Passes (Абонементи)
ADR: Implement Passes (Абонементи)
Section titled “ADR: Implement Passes (Абонементи)”Status
Section titled “Status”PROPOSED
Context
Section titled “Context”The backend passes feature is fully implemented (libs/features/passes/ in tktspace-backend):
- 5 tables in PostgreSQL
passesschema: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/*) andClientApiModule(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
findAndConsumeEntitlementForBillingandvalidateAndUseEntitlement.
The gap is two-sided:
- 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 neithertktspace-businessnortktspace-mobile-appcan talk to passes. - Neither
tktspace-business(operator UI) norapps/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.
Decision
Section titled “Decision”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.
Considered alternatives
Section titled “Considered alternatives”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.
Alt 3: Adopt polling on the LiqPay pending screen as a backup to the deep-link
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.
Surface-by-surface breakdown
Section titled “Surface-by-surface breakdown”tktspace-business (admin panel)
Section titled “tktspace-business (admin panel)”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/create→PassFormPage(create).passes/:id/edit→PassFormPage(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 forcustomerEntitlementId. The form enforces mutual-exclusion at the field level and disables thesubtractfield whenaddis 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}/passesrequiresREAD_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.
apps/gym_app (mobile customer app)
Section titled “apps/gym_app (mobile customer app)”New router routes added to apps/gym_app/lib/router/app_router.dart:
/companies/:companyId/passes→PassesCataloguePage(catalogue list)./companies/:companyId/passes/:passId→PassDetailPage(template detail, price-variant picker, “Buy” button — opens the pass-purchase checkout)./my-passes→MyPassesPage(segmented control: All / Active)./my-passes/:customerPassId→MyPassDetailPage(status, validUntil, per-activity sessionsRemaining, Cancel button if status in PENDING/ACTIVE)./payments/success→PaymentSuccessPage(deep-link landing — acceptspassIdandbookingIdquery params; reads which entitled pass is now ACTIVE from the relevantmineendpoint)./payments/failure→PaymentFailurePage(deep-link landing for cancel/error from gateway).- A pass-purchase entrypoint:
/checkout/pass/:passId→CheckoutPageparameterized viaPassPurchaseConfig(extra: passed viastate.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.
packages/api
Section titled “packages/api”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 } }—paymentonly 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.
packages/core
Section titled “packages/core”New files:
lib/src/passes/passes_repository.dart— thin facade overApi.invoke(...)for the five client endpoints. Returns parsed DTOs.lib/src/passes/passes_state.dart—ChangeNotifier(matching the existingCheckoutServicestyle) holding catalogue list and my-passes list with status filter. No Riverpod — confirmed by reading existingtktspace_core.dartwhich only exports a singleStatusBarService; the project usesprovider+ChangeNotifier.- Export them from
packages/core/lib/tktspace_core.dart.
passes_state.dart exposes:
loadCatalogue(companyId),cataloguegetter,loadingCatalogueflag,error.loadMine(companyId, {String? status}),minegetter,mineFiltersetter (broadcastsnotifyListeners).cancel(companyId, customerPassId)→ reissuesloadMineafter success.entitlementsForActivity(companyId, activityId)— passthrough to repo, used by booking summary step.
packages/checkout
Section titled “packages/checkout”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.
Two distinct flows
Section titled “Two distinct flows”| Flow | AC coverage | Item config | Confirm handler | API endpoint hit |
|---|---|---|---|---|
| Pass purchase | AC-12, AC-13, AC-14, AC-18 | PassPurchaseConfig | PassCheckoutService.confirm | POST /companies/{cid}/passes/purchase |
| Booking with pass | AC-16, AC-17 | existing BookingConfig (extended optional entitlementId) | existing booking confirm handler | POST /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.dartUNCHANGED. The pass-checkout route composes the existing summary step’s payment-method picker, LiqPay redirect handler, and pending-state primitives. No edits tosummary_step.dartfor the purchase flow. - Booking-with-pass flow (AC-16/17): EDITS
lib/src/steps/summary_step.dartto compose a NEWUsePassPickerwidget whencompanyIdANDactivityIdare 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:
- Calls a READ-ONLY query
GET /companies/{cid}/passes/activities/{aid}/my-entitlementsto populate the picker. No purchase logic is invoked from the booking path. - Returns a single
entitlementId: string?that the booking-create call optionally forwards. - Mutates pass state SERVER-SIDE only — the existing backend
validateAndUseEntitlementcall insideBookingsClientService.createis the authority. The mobilePassCheckoutServicestate machine is NOT touched by the booking path; the booking path also does not importpass_checkout_service.dart. UsePassPickerlives inpackages/ui(a presentation package), not inpackages/checkout. It receives(companyId, activityId), queriesPassesRepository(inpackages/core), and emits anentitlementId?. 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).
Files added (NEW) vs edited
Section titled “Files added (NEW) vs edited”NEW under packages/checkout/lib/src/:
pass_purchase/pass_purchase_config.dart— declaresPassPurchaseConfig(immutable value type).class PassPurchaseConfig {final String passId;final String priceVariantId; // priceId on backendfinal 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— declaresPassCheckoutService extends ChangeNotifier. Holdsconfig,selectedPaymentMethod, exposessetConfig,setPaymentMethod,reset,confirm. Theconfirmmethod calls the generatedapiClientCompaniesCompanyIdPassesPurchasePostwithresultUrl = config.successDeepLink(do not introduce a newsuccessDeepLinkfield 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 fromsummary_step.dart, no edits).pass_checkout_page.dart— top-level route widget that wiresPassCheckoutServiceand the steps above.pending_pass_payment_page.dart— shown after returning to app from gateway (deep-link OR user manual back). ReadscustomerPassIdand re-fetches vialistMine. No polling. Resolves when the pass status flips fromAWAITING_PAYMENTtoACTIVE(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 newUsePassPickerwidget when the active booking config exposes bothcompanyIdandactivityId. The widget reports anentitlementId?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 OPTIONALentitlementId: 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 inpackages/uibecause it composes the already-sharedentitlement_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 callsPassesRepository.entitlementsForActivity(declared inpackages/core) and emits a singleentitlementId?. 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';packages/ui
Section titled “packages/ui”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 perCustomerPassStatusEnumvalue:AWAITING_PAYMENT(amber),PENDING(grey),ACTIVE(green),PAUSED(blue),EXPIRED(red-muted),CANCELLED(red).lib/src/components/entitlement_chip.dart— small chip showingactivityName · sessionsRemaining/sessionsLimit(renders∞ifsessionsLimit == null).lib/src/components/use_pass_picker.dart— picker used by the booking summary step (AC-16/17). Receives(companyId, activityId), queriesPassesRepository.entitlementsForActivity(inpackages/core), composesentitlement_chip.dartrows, and emits a singleentitlementId?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#…).
tktspace-web and tktspace-landing
Section titled “tktspace-web and tktspace-landing”Not touched in this ticket (per spec out-of-scope).
Super-admin
Section titled “Super-admin”Not touched (per spec out-of-scope).
Endpoint inventory
Section titled “Endpoint inventory”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”| Method | Path | Request body | Response 2xx | Permission | Backend handler |
|---|---|---|---|---|---|
| GET | /passes | — | PaginatedPassResponseDto | MANAGE_ACTIVITIES | PassesAdminController.findAll |
| GET | /passes/{id} | — | PassResponseDto | MANAGE_ACTIVITIES | PassesAdminController.findOne |
| POST | /passes | CreatePassDto | 201 PassResponseDto | MANAGE_ACTIVITIES | PassesAdminController.create |
| PATCH | /passes/{id} | UpdatePassDto | PassResponseDto | MANAGE_ACTIVITIES | PassesAdminController.update |
| POST | /passes/{id}/toggle | — | PassResponseDto | MANAGE_ACTIVITIES | PassesAdminController.toggle |
| GET | /customers/{customerId}/passes?status=&limit=&page= | — | PaginatedCustomerPassResponseDto | READ_CUSTOMERS | CustomerPassesAdminController.findAll — status typed as CustomerPassStatus enum (audit fix #3) |
| POST | /customers/{customerId}/passes | IssuePassDto | 201 CustomerPassResponseDto | MANAGE_CUSTOMERS | CustomerPassesAdminController.issue |
| POST | /customers/{customerId}/passes/{customerPassId}/pause | — | CustomerPassResponseDto | MANAGE_CUSTOMERS | CustomerPassesAdminController.pause |
| POST | /customers/{customerId}/passes/{customerPassId}/resume | — | CustomerPassResponseDto | MANAGE_CUSTOMERS | CustomerPassesAdminController.resume |
| PATCH | /customers/{customerId}/passes/{customerPassId}/adjust | AdjustCustomerPassDto | CustomerPassResponseDto | MANAGE_CUSTOMERS | CustomerPassesAdminController.adjust |
| DELETE | /customers/{customerId}/passes/{customerPassId} | — | CustomerPassResponseDto | MANAGE_CUSTOMERS | CustomerPassesAdminController.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”| Method | Path | Request body | Response 2xx | Backend handler |
|---|---|---|---|---|
| GET | /companies/{companyId}/passes | — | PassClientDto[] | PassesClientController.listAvailable |
| GET | /companies/{companyId}/passes/mine?onlyActive= | — | MyPassDto[] | PassesClientController.listMine — onlyActive boolean filter (audit fix #5) |
| GET | /companies/{companyId}/passes/activities/{activityId}/my-entitlements | — | MyEntitlementForActivityDto[] | PassesClientController.getMyEntitlements |
| POST | /companies/{companyId}/passes/purchase | PurchasePassDto | 201 MyPassDto (with optional payment block for LIQPAY) | PassesClientController.purchase |
| POST | /companies/{companyId}/passes/{passId}/cancel | — | MyPassDto | PassesClientController.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) vsPassResponseDto(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.cancelRefundPolicyIS surfaced onPassClientDto(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) vsCustomerEntitlementResponseDto(business): Client form omitsisActive(never relevant — the consumer only sees their active entitlements anyway).MyEntitlementDto.idis the entitlement’s UUID, which is forwarded asentitlementIdwhen booking with a pass.MyPassDto(client) vsCustomerPassResponseDto(business): Client form omitscustomerId(caller IS the customer),paymentMethod(not customer-facing — the wallet/LiqPay distinction is shown via balance changes elsewhere),pausedAt(not surfaced — the customer only seesstatus: PAUSED),createdAt,updatedAt. AddspassName(denormalized so the client doesn’t need to call/companies/{id}/passesto render a row).- Audit fix #4 — list response shape: The new
PaginatedPassResponseDto.items[]isPassResponseDtoincludingentitlementsandpricesarrays. The client side does NOT need this enriched form becauselistAvailablePassesalready returns the full shape (PassClientDtowith entitlements + prices baked in). So fix #4 is a business-surface-only change. CustomerPassesListQueryDto.status(admin) andPassesClientController.listMine.onlyActive(client): The two surfaces deliberately diverge. Admin (audit fix #3) gets a typedstatus: CustomerPassStatusquery param so operators can filter the customer-detail passes tab by any specific lifecycle state. Client (audit fix #5) gets a SIMPLERonlyActive: booleanquery param — whentrue, returns only passes with statusACTIVEorPAUSED(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 syntheticACTIVE_OR_PAUSEDvalue or array-of-status semantics). The sharedCustomerPassStatusschema 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).
Data model changes
Section titled “Data model changes”| Audit fix | What changes | Migration required? |
|---|---|---|
| #1 | UpdatePassDto: remove (i.e. don’t add) isActive field — already absent in current code; keep absent. NestJS DTO only. | No |
| #2 | AdjustCustomerPassDto: 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 |
| #3 | CustomerPassesListQueryDto.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 |
| #4 | PassesService.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 |
| #5 | PassesClientController.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 |
| #6 | PassesSchedulerService.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. |
| #7 | PassesClientService.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.purchasereturn shape. Readtktspace-backend/libs/features/passes/src/lib/services/passes-client.service.ts:- WALLET branch (line 117) returns
createPassWithEntitlements(...)whose result is{ customerPass: { ...customerPassRow, passName }, entitlements: [...] }.entitlementsis a SIBLING ofcustomerPass. - MANUAL branch (line 122) returns the same shape —
entitlementsSIBLING. - LIQPAY branch (line 156) destructures the helper result and re-wraps it as
{ customerPass: { ...customerPass, passName: pass.name, entitlements }, payment }—entitlementsNESTED incustomerPass.
Three branches, three shapes. Generated TS (ng-openapi-gen) and Dart (chopper_generator) clients cannot deserialize this; tests that pass against
PurchasePassResponsewould fail against the actual backend.Required normalization: every branch returns
{customerPass: MyPassDto, // entitlements REQUIRED nested field of MyPassDtopayment?: PaymentInitiationDto // present only for LIQPAY (and future MONOPAY)}Implementation:
- Edit
createPassWithEntitlements(lines 198-220) so the returnedcustomerPassalready containsentitlements. Change the finalreturn { customerPass: { ...customerPass, passName: pass.name }, entitlements };toreturn { 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(...); thenreturn { customerPass, payment }. (Drop the local re-flattening.)
Contract verification:
PurchasePassResponseinclient.openapi.yamlis{ customerPass: MyPassDto, payment?: { id, redirectUrl } }, andMyPassDtoalready listsentitlementsin itsrequiredarray. 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
entitlementsfrom the response payload to display per-activity sessions remaining). - WALLET branch (line 117) returns
Drizzle schema patch (audit fix #6)
Section titled “Drizzle schema patch (audit fix #6)”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),}));Backend module placement
Section titled “Backend module placement”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 astatusquery param (#5).libs/features/passes/src/lib/services/passes.service.ts— patchfindAllfor fix #4.libs/features/passes/src/lib/services/passes-client.service.ts— patch line 117 for fix #7; threadstatusthroughlistMyPasses; patchcreatePassWithEntitlements(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— patchadjustfor fix #2.libs/features/passes/src/lib/services/passes-scheduler.service.ts— refactorhandleLowSessionsNotificationsfor 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.
Frontend implications (per app)
Section titled “Frontend implications (per app)”tktspace-business
Section titled “tktspace-business”- 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.tsaddPassessub-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.
tktspace-mobile-app / apps/gym_app
Section titled “tktspace-mobile-app / apps/gym_app”- New pages under
apps/gym_app/lib/pages/passes/andapps/gym_app/lib/pages/payments/(success/failure landing pages — see Deep-linking). - Router: edit
apps/gym_app/lib/router/app_router.dartto add the routes listed in “Surface-by-surface breakdown / apps/gym_app”. - Booking integration touch: edit
packages/checkout/lib/src/steps/summary_step.dartto compose the newUsePassPickerwidget (declared inpackages/ui) whencompanyIdANDactivityIdare present on the booking config, and editpackages/checkout/lib/src/models/booking_config.dartto thread the optionalentitlementIdthrough 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 thepackages/checkoutsection above for the full justification that this UI inclusion does not violate the spec’s “no shared business logic” guarantee.
tktspace-web
Section titled “tktspace-web”Not touched.
tktspace-landing
Section titled “tktspace-landing”Not touched.
Mobile architecture (recap of package responsibilities)
Section titled “Mobile architecture (recap of package responsibilities)”| Package | Touched? | Files added |
|---|---|---|
api | Yes (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. |
core | Yes | lib/src/passes/passes_repository.dart, lib/src/passes/passes_state.dart; export from tktspace_core.dart. |
checkout | Yes | NEW 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. |
ui | Yes | lib/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, profile | No | — |
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.
Deep-linking on mobile (concrete plan)
Section titled “Deep-linking on mobile (concrete plan)”Existing scheme
Section titled “Existing scheme”Confirmed by reading:
apps/gym_app/android/app/src/main/AndroidManifest.xmllines 27-32 — registers anintent-filterfor schemecom.fitspace.client.app(the bundle id; the value is hard-coded today, but is generated per-brand by theadd:brandmelos script).apps/gym_app/ios/Runner/Info.plist—CFBundleURLTypesregisters$(PRODUCT_BUNDLE_IDENTIFIER)as a URL scheme.
So both platforms already accept incoming URLs of the form <bundle-id>://<host>/<path>?<query>.
Per-brand scheme
Section titled “Per-brand scheme”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=….
Race conditions and resolution
Section titled “Race conditions and resolution”The deep-link is the SOLE UX resolution path (per spec AC-14 explicit non-goals). The race to consider:
- User pays in LiqPay → backend webhook fires asynchronously and flips
customer_pass.statusfromAWAITING_PAYMENTtoACTIVE. - LiqPay redirects user back to the app via deep-link; the app lands on
/payments/success.
Cases:
- Webhook fires BEFORE deep-link:
/payments/successquerieslistMineand finds the pass alreadyACTIVE. Show success. - Deep-link fires BEFORE webhook:
/payments/successquerieslistMineand finds the pass stillAWAITING_PAYMENT. The page must NOT show a permanent failure — it shows a “confirming…” state and re-fetcheslistMineonce on a debounced user-driven refresh (pull-to-refresh) or onWidgetsBindingObserver.didChangeAppLifecycleState → resumed. This is NOT polling — it is single-shot per UI event. If the customer leaves the screen, navigation pushes to/my-passesand 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_PAYMENTrows 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”).
- 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
- 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 staysAWAITING_PAYMENTuntil the backend reconciliation cron transitions it toCANCELLED(existing behavior; no new code). - Cold launch via deep-link:
go_router’sinitialLocationis/home/main, but the deep-link is honored via the standard plugin handler.app_router.darttakes care because the route is declared. - Deep-link is dropped (e.g. user kills app): customer opens
/my-passeslater; the list shows the pass in whatever status the webhook produced. No data loss.
LiqPay vs Monopay
Section titled “LiqPay vs Monopay”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.
State machines
Section titled “State machines”Customer pass status
Section titled “Customer pass status” ┌────────────────────────┐ (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) → CANCELLEDAudit 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.
Test plan per AC
Section titled “Test plan per AC”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 / Fix | Surface | Test framework | Test file path |
|---|---|---|---|
| AC-0 | tktspace-business | Karma unit + Cypress E2E | client/src/app/core/services/menu.service.spec.ts; cypress/e2e/passes/app-shell.cy.ts |
| AC-1 | tktspace-business | Karma unit + Cypress E2E | client/src/app/features/dashboard/activities/pages/passes-list/passes-list.page.spec.ts; cypress/e2e/passes/passes-list.cy.ts |
| AC-2 | tktspace-business | Karma unit + Cypress E2E | client/src/app/features/dashboard/activities/pages/pass-form/pass-form.page.spec.ts (create); cypress/e2e/passes/pass-create.cy.ts |
| AC-3 | tktspace-business | Karma unit + Cypress E2E | pass-form.page.spec.ts (edit branch); cypress/e2e/passes/pass-edit.cy.ts |
| AC-4 | tktspace-business | Cypress E2E | cypress/e2e/passes/pass-toggle.cy.ts |
| AC-5 | tktspace-business | Karma unit | passes-list.page.spec.ts (filter branch) |
| AC-6 | tktspace-business | Karma unit + Cypress E2E | client/src/app/features/dashboard/customers/pages/customer-detail/customer-detail.page.spec.ts; cypress/e2e/passes/customer-passes-list.cy.ts |
| AC-7 | tktspace-business | Karma unit + Cypress E2E | client/src/app/features/dashboard/customers/modals/issue-pass/issue-pass.modal.spec.ts; cypress/e2e/passes/issue-pass.cy.ts |
| AC-8 | tktspace-business | Cypress E2E | cypress/e2e/passes/customer-pass-pause.cy.ts |
| AC-9 | tktspace-business | Cypress E2E | cypress/e2e/passes/customer-pass-resume.cy.ts |
| AC-10a–c | tktspace-business | Karma unit + Cypress E2E | client/src/app/features/dashboard/customers/modals/adjust-customer-pass/adjust-customer-pass.modal.spec.ts; cypress/e2e/passes/customer-pass-adjust.cy.ts |
| AC-10d | backend | Jest unit + e2e | libs/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-11 | tktspace-business | Cypress E2E | cypress/e2e/passes/customer-pass-cancel.cy.ts |
| AC-12 | gym_app | Flutter widget + integration | apps/gym_app/test/pages/passes/passes_catalogue_page_test.dart; apps/gym_app/integration_test/passes_catalogue_test.dart |
| AC-13 | gym_app | Flutter integration | apps/gym_app/integration_test/passes_purchase_wallet_test.dart |
| AC-14 | gym_app | Flutter integration + manual smoke | apps/gym_app/integration_test/passes_purchase_liqpay_test.dart (mocked redirect); manual deep-link smoke documented in docs/manual-tests/passes-deeplink.md |
| AC-15 | gym_app | Flutter widget + integration | apps/gym_app/test/pages/passes/my_passes_page_test.dart; apps/gym_app/integration_test/my_passes_test.dart |
| AC-16 | gym_app | Flutter widget | packages/ui/test/components/use_pass_picker_test.dart; packages/checkout/test/steps/summary_step_test.dart (renders picker when companyId+activityId present) |
| AC-17 | gym_app | Flutter integration | apps/gym_app/integration_test/booking_with_pass_test.dart |
| AC-18 | gym_app | Flutter widget + integration | apps/gym_app/test/pages/passes/cancel_pass_sheet_test.dart; apps/gym_app/integration_test/passes_cancel_test.dart |
| AC-19 | backend | Jest unit | libs/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 #1 | backend | Jest unit | libs/features/passes/src/lib/dto/passes-admin.dto.spec.ts — UpdatePassDto rejects/strips isActive |
| Fix #2 | backend | Jest unit + e2e | passes-admin.dto.spec.ts (mutex constraint); apps/api-e2e/src/passes/customer-passes.e2e-spec.ts (adjust endpoint) |
| Fix #3 | backend | Jest unit + e2e | passes-admin.dto.spec.ts (@IsEnum); apps/api-e2e/src/passes/customer-passes.e2e-spec.ts (status filter rejects invalid values) |
| Fix #4 | backend | Jest unit + e2e | libs/features/passes/src/lib/services/passes.service.spec.ts (findAll returns entitlements + prices); apps/api-e2e/src/passes/passes-admin.e2e-spec.ts |
| Fix #5 | backend | Jest unit + e2e | libs/features/passes/src/lib/services/passes-client.service.spec.ts (status threading); apps/api-e2e/src/passes/passes-client.e2e-spec.ts |
| Fix #6 | backend | Jest unit | passes-scheduler.service.spec.ts (single-query refactor — same file as AC-19) |
| Fix #7 | backend | Jest unit + e2e | passes-client.service.spec.ts (WALLET branch sets ACTIVE + activatedAt + validUntil); apps/api-e2e/src/passes/passes-client.e2e-spec.ts |
| Fix #8 | backend | Jest unit + e2e | passes-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 stuckAWAITING_PAYMENTrows to a terminal state, so the user’s next list fetch resolves it. - Refund-policy edge case on cancel:
calculateRefundAmount(lines 259-295 ofcustomer-passes.service.ts) handlesPROPORTIONALonly 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
hasPermissionutility (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-suppliedIdempotency-Keyheader toPOST /passes/purchaseand 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 oncustomer_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 generatemigration only adds a B-tree index. Drizzle emits plainCREATE INDEX(no CONCURRENTLY) which acquires a SHARE lock oncustomer_entitlementfor 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 toCREATE INDEX CONCURRENTLY(run manually outside the migration runner, then mark the Drizzle migration applied) ONLY if productioncustomer_entitlementrow count exceeds ~500k at deploy time. Themigration-safety-checkskill MUST be run on the generated SQL inlibs/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.
Rollout plan
Section titled “Rollout plan”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:
_workflowMR (this ADR + contract patches at0.2.0) lands onmain.tktspace-backendMR — 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 (singledrizzle-kit generaterun), runsmigration-safety-checkskill on the produced SQL. Deploys to staging.- In parallel:
tktspace-businessMR —npm run generate:apithen implements the new pages/modals. - In parallel:
tktspace-mobile-appMR —melos run sync:spec && melos run generate:api, implements the four touched packages and the new gym_app pages.
Backwards-compat:
AdjustCustomerPassDtoshape 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
onlyActivequery param on clientlistMineis additive — old callers omit it (or passfalse) and get unfiltered results (previous behavior). Safe. - The
findAllresponse now includesentitlementsandprices. Schema change adds two array fields — additive on the response. Safe. - Fix #8 (purchase return shape normalization) reshapes the
POST /companies/{cid}/passes/purchaseresponse. The previous WALLET/MANUAL shape (entitlementssibling ofcustomerPass) is replaced with the LIQPAY shape (entitlementsnested incustomerPass) 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