Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSubscriptions PlansSubscriptions & Plans — Behavior

Subscriptions & Plans — Behavior

Cash collection rail

Cash-plan enrollment and prepayment are available for paid, non-course plans. Existing subscriptions remain on their stored card rail: recording cash against one uses the legacy manual-payment workflow and never converts the agreement. Subscription responses expose paymentCollectionMethod and cashPrepaidThrough; cash rows use billing state prepaid, not an automatic recurring charge promise. The current quota cycle remains separate from the complete purchased horizon.

Cash quotes price every ordinary period in the plan’s currency, including intro graduation and time-bounded discounts. A confirmed quote and operation key bind the payment, subscription, immutable coverage allocations and renewal task in one transaction. Retries of an established operation replay its result. A changed catalogue currency cannot be mixed into an existing cash agreement. Receipt failure is a durable payment success with a document-reconciliation obligation, not permission to collect again.

Recurring enrollment uses the same member/plan advisory boundary for cash, saved-card staff enrollment, presale and hosted-checkout creation. Eligibility is checked inside that boundary. Consumable class packs and drop-ins can still be stacked.

Members may request cash subscription cancellation. Staff issue the existing request with its original timestamp, reason, retention task and cancellation-form contract. The ordinary notice clock applies; paid quota cycles continue only to the earlier of the paid-through and cancellation dates. At the effective date, access ends atomically with per-payment refund obligations calculated from the immutable allocations. Cash is not marked returned by a cron: each sale’s unused amount is reserved as a refund_pending refund row with a manual-refund task, and staff settle it with Confirm cash returned, which files the credit note — closing that task by hand is refused (refund_task_use_cash_settlement). Business termination cannot bypass a pending member request or issued notice.

Staff ending a cash plan now (business termination) may issue a credit note with it. The staff cancel dialog (staff-cancel-dialog.tsx, cash branch: a preview carrying capInCents) offers the credit when the cap is above zero, defaults the amount to the preview’s creditableInCents (ticked only when that is above zero), lets staff edit it between 1 cent and the cap, and has a Cash returned switch (on by default; off cancels the invoice with no cash out). The body is refund: { transactionId, amountInCents, cashReturned }. Without a credit a reason is required. Amounts, cap, document type and the reservation flow are specified in payments › Cash credit notes.

One-time packs retain their unused-credit refund basis and may be frozen; unfreezing preserves credits and extends any finite validity by the frozen time. A non-metered (unlimited) one-time sale’s suggested credit is time-based — the unused share of its period, or all of it before it starts; a pack that never started is suggested in full. A matured member notice on a non-metered one-time sale still opens a manual review task rather than a reservation: a cron does not decide what such a holder used.

Cash plan changes (including complimentary changes) remain unavailable because prepaid allocations cannot be transferred automatically. Staff and member surfaces do not advertise those actions; a directly opened change dialog explains the restriction. Card-rail immediate complimentary changes remain supported without a saved card, quote zero due now and retain the actual old billing cadence. Preview errors are displayed and never treated as a usable cached quote.

Plan CRUD

PlansController (apps/api/src/plans/plans.controller.ts) — all routes @Controller('organizations/:orgId/plans').

RouteMethodGuardNotes
/POSTowner/admin + @RequiresFeature('membership_plans')Validates type constraints: subscription needs interval; class_pack needs classCredits. class_pack also requires the class_packs feature (service check at plans.service.ts:62).
/GETactive memberLists plans; default filter isActive=true. Cache-Control: private, max-age=120, stale-while-revalidate=600.
/:idGETactive member
/:idPATCHowner/admin + @RequiresFeaturePartial update; updates plan.currency if passed.
/:idDELETEowner/admin + @RequiresFeatureSoft-delete via isActive=false.
/:id/purchasePOSTactive memberDrives the member checkout. Rejects plan.type='course' (must go through /public/courses/:id/checkout).
/reorderPATCHowner/admin + @RequiresFeatureRenumbers sortOrder by position. Partial-list semantics: ids not passed keep their order. Never touches planGroupId.
/:id/subscribersGETsubscriptions:view (owner/admin — not coach)The plan’s subscriptions with member identity attached, ranked live → in-flight → the rest, newest first inside each. Everything still live or in flight comes back whole (bounded by the gym’s size); the cancelled tail is capped at 200, since it grows by a row per member per re-subscribe forever. The response carries total — the true all-time count — so the page never infers it from a truncated list.

Plan detail page

/dashboard/plans/:id (linked from the plan name in the table) is the plan’s own surface: its terms as chips, KPI tiles (active members, in-flight, seats or all-time, recurring value), and the member list from /:id/subscribers with a link per row into the member’s page. Edit reuses the plan sheet; archive and restore are the isActive PATCH the table already used.

Recurring value prices each live subscription at what it is ACTUALLY paying (effectivePriceInCents with the sub’s introCyclesRemaining), so a founders’ discount is not counted at list price; on a pack or drop-in (no interval) the same figure is labelled as sales value, not per-cycle. An intro offer gets its own tile spelling out the calculation — intro price, cycles, then the standard price — plus how many live members sit on each side of it, and the member table shows what each one pays next. Coaches see the page without the member list — that half is billing data, and the page never even requests it.

The seats tile reports the BINDING cap, which may be the group’s rather than the plan’s own (capScope): its total is seatsTaken + seatsLeft, never maxPurchases, which is only ever this plan’s.

Members may see less than that. A group carries a showCapToMembers toggle (default on, edited in the group sheet’s Purchase limit section): with it off, a member-facing plan whose BINDING cap is that group gets soldOut and nothing else — no seatsTaken/seatsLeft/capScope, so the shop card drops its “N spots left” badge while the sold-out badge and the disabled buy button still work. Staff surfaces, including this tile, always show the real numbers. See data-model.md § Hiding the seat count from members.

Display order and plan groups (FIT-289)

Cap enforcement and form resolution always read the data, never a flag.

plans.sortOrder is the single source of truth for the whole owner-facing table. The table draws that one sequence as blocks — each group’s plans are a contiguous run, and so are the ungrouped ones — and a block’s position is the position of its first plan. planGroups.sortOrder only orders groups that hold no plans yet.

Dragging changes position, never membership. Two gestures, kept apart:

  • A row’s handle moves the plan inside its own block. A drop outside that block is refused and the row snaps back — leaving a group would move the plan under a different purchase cap, which is a change of offer, not of order.
  • A block header’s handle moves the whole block — header and every plan under it — anywhere in the table. The ungrouped block gets a header of its own (labelled “Ungrouped”) as soon as a group exists, so a group can sit above, below, or between the ungrouped plans. A group with no plans has no place in the sequence: its handle is disabled and it waits at the bottom.

Both gestures send the same thing: the full displayed sequence to PATCH /plans/reorder, which numbers sortOrder by position across the org.

Membership is changed explicitly, from the “move to group” radio list in the plan row’s actions menu (or the plan sheet’s group picker). Both go through PATCH /plans/:id { planGroupId }, which validates via assertPlanGroupAssignable and audits as PLAN_UPDATED. Course plans are refused there — their checkout never runs the seat guard, so a group cap on them would silently not enforce — and the menu shows that instead of offering groups.

With no groups at all the table is a flat headerless list, exactly as before groups existed. Dragging is disabled entirely while a search/type filter is active or the archived view is on: the visible subset doesn’t map onto the org-wide sequence.

Purchase flow (PlansService.purchase — plans.service.ts:200)

  1. Load the plan; reject if inactive, course-type, or missing. A once-per-person trial plan (is_trial + trial_once_per_person) bought by a non-staff actor is refused with 409 trial_already_used when the person being enrolled (the child, for a guardian) already held any trial plan of the org, or has an unpaid pending checkout of another one — assertTrialNotUsed, before the form gate; see Trials › One per person.
  2. Look up any non-deleted subscription for (membershipId, planId) with status in (active, scheduled, pending, paused, past_due, debt). The set must cover every status createSubscription’s exclusivity guard refuses; a holder missing from it falls through to that guard’s generic enroll_conflict (“couldn’t be completed, contact support if charged”), which is how a presale buyer re-opening their join link was reported as a failed checkout.
    • recurring plan, active | scheduled → 409 plan_already_held, body carries status and subscriptionId. Not a failure: the join flow (join-continue.tsx) shows the you-are-in screen and files no checkout-failed report.
    • recurring plan, paused → 409 Resume it instead of purchasing again.
    • past_due | debt → 409 You already have a subscription with an outstanding balance.
    • pending → reuse the row (generates a fresh payment page for it). When the caller passes resumeSubscriptionId (only resumeCheckout does), THAT row is reused specifically rather than whichever pending row for the plan comes back first — and if it is no longer pending, the call 409s checkout_not_resumable instead of minting a new one.
    • none → create a new pending (or active for free plans).
  3. Emit payment.checkout_started observability event.
  4. Free plan: return immediately with { subscription } (subscription.status='active').
  5. Paid plan: build ClientInfo from member profile (taxId, address, etc. for invoicing — plans.service.ts:321), call PaymentService.createHostedPayment.
  6. On payment-page creation failure, delete the pending subscription row so retries don’t accumulate dead rows — but only a row THIS call created. A reused pending row predates the request, may carry a live payment page and its own transactions, and is exactly what resumeCheckout is trying to finish; deleting it because the provider blipped would destroy what the member asked to resume.
  7. Return { subscription, paymentPageUrl }.

failedUrl rides along from step 5: when the caller doesn’t supply one it is derived from cancelUrl by flipping status=cancelled → status=failed (payments/checkout-urls.ts), so Cardcom can route a REFUSED charge somewhere other than the “you cancelled” page. See payments/behavior.md.

The hosted-page URL is followed by the client; activation happens via webhook (see payments/behavior.md).

replacesSubscriptionId hardening (FIT-254 review Wave B):

  • B3(b): dueNow < ₪1 now rejects with 400 PLAN_CHANGE_USE_SCHEDULE — this checkout path is for real upgrades only; a sub-₪1 result means the change should go through the change-plan endpoint instead (which schedules it at the boundary). The pre-existing Math.max(dueNow, 0) clamp is no longer reachable as the sole guard.
  • B6: cross-currency replace (old sub’s plan currency ≠ target plan currency) → 409 PLAN_CHANGE_CURRENCY_MISMATCH, checked before the proration math runs — same guard as every other computeDueNow call site.
  • B7: when a second in-flight replaces-checkout for a DIFFERENT target plan supersedes the first (deleting its now-stale pending row), that superseded sub’s own pending payment_transactions rows are now also marked cancelled. Its hosted payment page is still live at the provider — if the member somehow completes it anyway, the webhook finds no pending transaction to flip and no-ops with a clear trace, instead of writing into an orphaned subscription.

Subscription lifecycle endpoints

SubscriptionsController (apps/api/src/subscriptions/subscriptions.controller.ts).

RouteMethodRoleEffect
/subscriptions/myGETmemberLists own subs (excluding plan.type='course').
/members/:id/subscriptionsGETowner/admin (subscriptions:view)Read a member’s subs. 404 unless the membership belongs to :orgId (SubscriptionsService.getMemberSubscriptions(orgId, membershipId) scopes the lookup), so staff of one org never read another org’s member.
/members/:id/enrollPOSTowner/admin (+ payments:manage for paid plans)enrollMember — charges the member’s saved card (FIT-287). Body { planId, chargeNow?, startsOn?, paymentMethod?, periodCount?, negotiatedPriceInCents? }. startsOn is a calendar date in the org’s timezone; negotiatedPriceInCents: 0 is a gift.
/subscriptions/:id/charge-datePATCHowner/admin + payments:manageupdateChargeDate — moves next_charge_date, shifting current_period_end by the same delta.
/subscriptions/:id/renewPOSTowner/admin or sub ownerCharges via stored payment method synchronously; updates period.
/subscriptions/my/:id/cancellation-requestPOSTsub ownermemberRequestCancellation — requires a structured reason code (reason also required for other), stores the timestamped request, opens an owner-assigned request retention case, issues any published cancellation form, and returns its instance id. Does not schedule an end, set notice/effective dates, stop billing, clear a plan change, or notify cancellation. Live recurring rows only; duplicate request 409s.
/subscriptions/my/:id/resumePOSTsub ownerWithdraws a pending request or live issued notice. Clears cancellation fields, closes the retention case as saved, completes the task, and revokes unsigned cancellation paperwork. Supports all payment providers without changing their billing agreements. Already-ended rows and repeated calls with no open request/notice return 400.
/subscriptions/:id/cancelPOSTowner/admincancelSubscription — immediate; tries paymentService.cancelRecurring on the provider if providerSubscriptionId set.
/subscriptions/:id/void-pendingPOSTowner/adminstaffVoidPendingSubscription — dismiss a member’s dead checkout from the member page. Subscription-keyed twin of POST /payments/:txnId/void; same money-safety guard, same abandoned_checkout marker.
/subscriptions/:id/cancel (initiator: 'member_request')POSTowner/adminissueMemberCancellation — staff issue the legal notice. A pending mobile request supplies its original member timestamp/reason and is promoted in place; otherwise staff may record an offline notice. Sets cancelAtPeriodEnd=true, computes the one-month effective date, sends confirmation, issues an available cancellation form, and opens/promotes the retention case. Immediate mode is invalid.
/subscriptions/:id/cancellation-previewGETowner/adminpreviewCancellation — what the member is owed if cancelled now, plus the one-month notice-effective date for comparison. Optional ?noticeAt= (ISO datetime) backdates the notice-effective calculation to when the member actually gave notice, so the staff dialog’s preview matches the notice POST …/cancel is about to record. alreadyRefunded/refundableInCents correctly reflect a transaction already in refund_pending/refunded (previously always false — the query filtered status='completed', which such a transaction no longer carries). The “most recent charge/recurring row” lookup is filtered to status IN ('completed','refund_pending','refunded') — money that actually arrived, or arrived and is being given back — so a newer failed (declined) or pending/cancelled row can never outrank an older completed charge for “most recent” and describe a refund against payment the member never made.
/subscriptions/:id/retention/acknowledgePOSTowner/admin, subscriptions:manageAcknowledges an open retention case.
/subscriptions/:id/retention/contactPOSTowner/admin, subscriptions:manageBody { channel, note? }. Logs a contact attempt on an open case.
/subscriptions/:id/retention/outcomePOSTowner/admin, subscriptions:manageBody { outcome, note? }, saved* outcomes only. Sets the case’s outcome and clears an open request or live notice in the same action.
/subscriptions/:id/withdraw-noticePOSTowner/admin, subscriptions:manageBody { channel, note? }. Staff withdraw an issued notice on the member’s word, for any payment provider. The member’s equivalent is POST /subscriptions/my/:id/resume.
/subscriptions/my/:id/cancel-pendingPOSTsub ownermemberCancelPending — immediately cancels a pending sub (abandoned/incomplete checkout). Optional body { intent?: 'regret'|'abandon', source?: 'return_page'|'profile'|'shop' }; empty body = today’s behavior. See below.
/subscriptions/my/:id/resume-checkoutPOSTsub ownerPlansService.resumeCheckout — re-issues a hosted payment page for the SAME pending row. Body { successUrl?, cancelUrl?, failedUrl? }. Returns { subscription, paymentPageUrl, resuming: true }. 409 checkout_not_resumable (carrying status) once it isn’t pending.
/subscriptions/my/:id/checkout-returnPOSTsub ownerTelemetry only. Body { outcome: 'cancelled'|'failed'|'success_unverified', client: 'web'|'mobile' }. Emits payment.checkout_cancel_returned + payment.return_landed; mutates nothing, safe to call repeatedly.
/subscriptions/my/:id/withdrawPOSTsub ownermemberWithdrawScheduled — drops a scheduled (presale) membership free of charge. See below.
/subscriptions/:id/freezePOSTowner/adminactive → paused; stamps pausedAt.
/subscriptions/:id/resumePOSTowner/adminpaused → active; extends currentPeriodEnd by (now - pausedAt).
/subscriptions/:id/adjust-creditsPOSTowner/admin, subscriptions:manageBody { amount, reasonCode, note? }. Balance += amount (clamped ≥ 0), ledger adjustment + entitlement.adjusted audit. Refuses unlimited plans.

Staff issuance of a member cancellation (§5) routes through issueMemberCancellation, not through the immediate business cancel. A member may first create a timestamped mobile request through memberRequestCancellation; that request is only a staff obligation. It leaves billing, access, cancelAtPeriodEnd, notice/effective dates, scheduled plan changes, and cancellation notifications untouched, but issues any published request form for immediate signature. The owner/admin issues the legal cancellation from the retention task. Issuance uses the original member request timestamp and reason, computes the one-month effective date, promotes the same task to notice, sends confirmation, and reuses or recognizes the request form instead of creating duplicate paperwork.

Staff can also record an offline member notice directly through the same issuance method. Two identities remain distinct: the authenticated staff actor is audited as issuer; the subscription owner receives the legal cancellation document and remains the task’s linked user.

Renew (member/staff-triggered) — C1 fix, status gate (A1)

POST /subscriptions/:id/renew (subscriptions.service.ts, renewSubscription). Behavior change (FIT-254 C1, unflagged — the prior behavior was a bug, not a feature to preserve):

  • Before: called paymentService.charge with no token at all (worked only by accident on providers that don’t require one; failed outright on Cardcom/iCredit/Morning), and unconditionally advanced currentPeriodEnd + reset credits even when the charge failed — silently granting a free period on a declined card.
  • Now: resolves the membership’s active payment method first (paymentMethodService.getActivePaymentMethod) — 409 NO_ACTIVE_PAYMENT_METHOD if none — then charges with the real token. On failure: nothing is written to the row (a member trying to buy and failing owes nothing; no debt is opened), and the endpoint returns 402 with { code: PaymentErrorCodes.RENEWAL_CHARGE_FAILED }. On success: period advances and credits refill exactly as before.

This is a genuine behavior change for any caller depending on the old “always succeeds, sometimes for free” semantics — flag it in QA sign-off even though it ships unflagged (CLAUDE.md: propose a flag for every behavioral change — this one shipped unflagged because the old behavior was a bug the team judged unsafe to preserve; see the ADR for the reasoning).

Plan kind first: a punch card (class_pack) or drop-in is refused with 400 renew_unsupported_plan_type before the status gate below, whatever state it is in. Renew reactivates the row in place, skipping the purchase cap and the compliance-form gate PlansService.purchase enforces, so a pack is bought again through the shop. resolveMemberAction / resolveMemberActions never offer renew for a pack (cancelled or past_due), so canMemberRenew agrees.

Status gate (FIT-254 review Wave A, A1, also unflagged bug fix — the missing gate let the cron re-arm incorrectly): renew is only valid from past_due or cancelled:

Sub statusResult
past_due✅ proceeds (legacy rows only; billing no longer writes past_due)
cancelled✅ proceeds (bring a fully-ended sub back with a fresh period) — except a row still owing debt (debt_amount_in_cents > 0, e.g. cancelled for non-payment): 409 DEBT_CLEARANCE_REQUIRED
active409 SUBSCRIPTION_ALREADY_ACTIVE — already current; renewing would double-charge the period the member is already paying for
debt409 DEBT_CLEARANCE_REQUIRED — pay the debt (clear-debt / member pay-debt) instead, which restores the period it covered
paused409 RESUME_FIRST — resume before renewing (mirrors plan-change’s resume_first)
pending400 — checkout never completed, nothing to renew

Success-path fields (A1): the old success .set() never touched nextChargeDate/failedChargeAttempts — a renewed subscription’s stale nextChargeDate (from before it fell behind) stayed in place, so the very next recurring-charge cron tick would immediately re-select and re-charge it, double-billing the member who just paid to renew. Now also sets nextChargeDate = currentPeriodEnd (subscription-type plans with an interval) and failedChargeAttempts: 0.

Temporary retention pricing

PATCH /organizations/:orgId/subscriptions/:id/discount accepts two mutually exclusive modes:

  • { negotiatedPriceInCents, discountReason? } keeps the existing permanent/manual price override. null clears all discount state.
  • { discountPercent, discountDurationMonths, discountReason? } creates a bounded percentage offer. Percentage is an integer from 1–100 and duration is 1–120 calendar months.

Bounded offers are available only when the plan is billed by an active, charge-verified Taikan-managed provider (cardcom, meshulam, or test) and has no external payment link. External providers, missing configuration, pending KYC, inactive configuration, and unsupported providers fail closed. This restriction applies only to changing money; the provider-neutral I want to stay action remains available for external subscriptions.

The offer starts at the next scheduled charge (nextChargeDate, then currentPeriodEnd, then now; stale dates clamp to now) and ends after the configured number of UTC calendar months with month-end clamping. The subscription stores the percentage, duration, start, end, and the quoted discounted amount. Every charge path resolves the live plan list price through chargeableAmountInCents: within [discountStartsAt, discountEndsAt) it charges round(listPrice × (100 − percent) / 100); outside that window the bounded snapshot is ignored and ordinary intro/list pricing resumes automatically. A later plan-price edit therefore changes future discounted charges proportionally rather than freezing the original quote.

The staff member card exposes the bounded offer directly from the retention case. Saving it first changes the subscription price, then records saved_discount, closes the pending request or notice, completes its task, and revokes only unsigned cancellation paperwork. Permanent/manual pricing remains a separate explicit mode.

Pending checkouts and capped seats

A never-activated pending subscription holds a purchase-cap seat for 45 minutes after its last checkout activity (subscriptions.updated_at). Older pending checkouts stop counting toward plans.max_purchases and plan_groups.max_purchases without waiting for a cleanup sweep.

This is a permanent rule for every organization, not a feature flag or payload setting. resolveSeatHoldOptions() synchronously calculates the cutoff exactly 45 minutes before the call; use that shared result rather than re-implementing the predicate. A checkout touched exactly at the cutoff still counts.

  • Enforcement and display move together. assertPlanSeatAvailable, the dashboard catalogue, the public minisite pricing DTO and the join page all pass the same SeatHoldOptions into the same seatStatusCondition. A page that says “3 left” while the guard says “sold out” is worse than either answer alone.
  • scheduled and ever-activated rows do not time out. Only the never-activated pending arm is time-boxed; sold presales and ever-activated subscriptions retain the existing seat rules, including cancellation freeing the seat.
  • Resuming re-stamps the clock, and a resume past the window re-claims a seat through the same locks a fresh purchase takes (PlansService.purchase’s reuse branch). A member who comes back to a long-dead checkout can find the offer gone — correct, since somebody else bought it.
  • Capacity expiry is not lifecycle cleanup. The helper only changes counting and the resume re-claim rule. It never cancels a subscription or membership, deletes an invitation or shell user, or touches a Clerk user. The separate cleanup paths below retain their own eligibility checks and schedules.

JoinService.resolve now runs the seat check for a link’s plan, so a full offer withdraws itself: plan goes null, planUnavailable is true, and planSoldOut: true tells the page to say “sold out” rather than “closed”. Registration itself stays open — the visitor can still join and pick something else.

Before this, resolveIntentPlan only asked whether the plan was active, in-shop and non-course. A sold-out plan rendered as a normal offer, so the visitor filled the form, created an account, became an active member consuming a tier seat, and only then hit a 409 plan_sold_out at checkout — on a page that could do nothing for them. (join-link.schema.ts had claimed for months that planUnavailable covered “archived, or sold out”; it did not.)

One layer up from the seat above: MembershipsService.acceptJoinLink makes a registrant active before any money moves, and nothing ever released that tier seat. A public join link therefore accumulates inert “Active” members who have paid nothing.

PaymentMonitoringService therefore also cancels the membership — and emits MEMBERSHIP_RELEASED_UNPAID, which the CRM turns into a lead (OrganizationLeadsService.demoteReleasedJoinMembership, lead source: 'join_link'). Contact details plus demonstrated intent is a prospect worth chasing, not a member.

The separate join-link cleanup uses JOIN_LINK_RELEASE_MINUTES = 45 as its eligibility window, for every org. This is not the seat-hold clock and does not run as part of a capacity read. The scheduled passes are:

  1. runSweepJoinLinkPendingCheckouts for unpaid join-link checkouts idle beyond 45 minutes. Idle is measured from the newest open payment attempt, falling back to the subscription’s creation time. The money-safety guard cancels the checkout before considering membership release.
  2. After the general abandoned-checkout sweep cancels a registrant’s only checkout. Its separate payment-cleanup timing remains unchanged (normally 24h idle, or 1h after a decline with no open attempt; see payment behavior).
  3. runSweepUnpaidJoinMemberships for registrants who never started a checkout at all, eligible after 45 minutes. Scoped to orgs with an active payment provider — in an org that takes no payments, a join-link member with no subscription is the finished state.

The eligibility bar is deliberately narrow, because wrongly cancelling a real member is far worse than a leaked seat. ALL must hold: membership still active, role member, source = 'join_link', no other non-cancelled subscription, no settled transaction ever, and never activated or presale-committed — anyone who reached scheduled and later withdrew is churn, not a prospect, and is left alone.

Fully recoverable: a late payment revives the subscription and reactivates the membership (WebhookProcessingService, guarded to the demotion’s exact signature so a staff-cancelled member is never resurrected), re-emitting MEMBERSHIP_ACTIVATED so the reopened lead converts again. Walking back through the link reactivates the same membership as it always did.

Not demoted: someone already in. Before writing or reopening anything, demoteReleasedJoinMembership checks whether an active membership of the org — any membership other than the one just released — belongs to the same person (isActiveMemberOf), and stops if so. The lead lookup matches on that same identity, so without this a released duplicate — a join form re-submitted with a corrected email, whose first shell shares the member’s phone — would find the member’s own converted lead and reopen a paying founding member at “Incomplete signup” with a fresh contact task. Seen on kineticscf, 2026-09-03.

Who a release belongs to. Both automated CRM writes — this demotion and the activation that wins a lead (convertLeadOnSelfSignup) — used to find the lead by email OR phone, unordered, first row wins. A phone is not an identity: a family shares one, so does a front desk, and so does anyone testing the funnel. On kineticscf, 2026-09-01..03, 31 shell registrations carrying a single number all resolved to one lead ([email protected]), whose board position then flip-flopped between Converted and Awaiting payment 16 times.

One rule decides both (lead-identity.ts, isSameLeadPerson), and it is the one supersedeTypoShells already applies one step earlier in the join flow:

  • equal email is conclusive;
  • equal phone counts only when the name agrees — or the lead carries no name at all, since a phone-only enquiry has nothing to contradict — and no second, differently-named person in the org answers to that number (isPhoneSharedInOrg);
  • anything else is not a match: the release opens its own lead and the activation wins nothing. Both are recoverable; a wrong merge is not.

Candidates are ordered oldest-first, so two rows sharing a number never resolve by plan order. isActiveMemberOf reads the same rule, which is what stops a shared number from silently swallowing every demotion for it.

Reopening writes only what changes. reopenLeadAtStage used to re-stamp status, append its note and insert a lead_status_events row unconditionally: the production lead above collected 22 system timeline rows, 10 of them from_stage = to_stage, and a note of 13 identical sentences. Now a lead already sitting in the target stage records no transition, and a sentence its note already ends with is not repeated — while a genuinely new sentence (a refused card after an abandon) is still appended, because that changes what the coach should say. The note is appended in SQL, never read-modify-write: two releases in one sweep each held a snapshot taken before the other wrote, which lost 3 of 16 appends outright. For the same reason MEMBERSHIP_RELEASED_UNPAID is now emitted with emitAsync and awaited, so a sweep’s releases reach the CRM one at a time.

The duplicate itself is closed at the source. MembershipsService.createJoinInvitation only reuses a pending invitation when the email matches, and email is exactly what gets mistyped. So on every request it also looks for another pending join_link invitation in the org whose shell has never claimed a login and whose phone and name both match the submission (supersedeTypoShells): that is the same person correcting their own address, and the earlier shell is discarded (below) before the owner ever sees two of them. Phone alone is deliberately not enough (a family shares one; the second sibling’s registration must not revoke the first’s ticket), and nothing is touched without both. Best-effort: a failure here never costs the live submission.

Discarding an unclaimed join shell

One step earlier than the release above. Submitting the join form creates a shell user, a pending_invitation membership, an invitations row and a Clerk ticket in one go, before the visitor signs up. Someone who stops there has no subscription, so no checkout sweep can see them.

This is a third, distinct clock: JOIN_SHELL_RELEASE_HOURS = 24, applied to every org; neither the permanent 45-minute seat hold nor the 45-minute join-membership cleanup changes its eligibility or performs its deletions.

A shell is discarded, never cancelled (MembershipsService.discardUnclaimedJoinShell). A cancelled shell would stay on the roster as a former member, keep a live Clerk ticket that can mint an account for an address that was as likely as not mistyped, and keep that address occupied by a user row nobody can sign in to. Discarding means:

  • the membership is soft-deleted (deleted_at), so it leaves every org view — the row stays because audit rows point at it;
  • every pending invitation for that address in the org is deleted and its Clerk ticket revoked;
  • the shell user is scrubbed and its email tombstoned (deleted+<id>@deleted.taikan.fit) when this org was its only footprint, so the same person can register the address correctly;
  • outstanding paperwork is retired through MEMBERSHIP_DEACTIVATED.

Two callers:

  1. PaymentMonitoringService.runSweepAbandonedJoinShells — hourly, every org. A join_link shell still at pending_invitation after JOIN_SHELL_RELEASE_HOURS (24h) with no claimed login, no subscription and no settled transaction. It still emits MEMBERSHIP_RELEASED_UNPAID { reason: 'never_completed' } so the CRM opens a lead in “Didn’t finish signing up” — the event carries a contact snapshot because the user row is scrubbed before the listener runs. The “already an active member” guard above still applies, so a typo shell becomes no lead at all.
  2. supersedeTypoShells — immediately, on the corrected re-submission.

Refused outright: a claimed login (a signed-up person with a stuck shell is a defect to surface), and anything a concurrent acceptance has already flipped — the discard is a conditional update on pending_invitation.

Cancel a pending checkout (member self-serve)

POST /subscriptions/my/:id/cancel-pending (subscriptions.service.ts, memberCancelPending).

Before this endpoint existed, a pending subscription (checkout started, never finished — abandoned tab, declined-and-gave-up, etc.) had no member-facing way off it: it sat there until either PaymentMonitoringService.sweepAbandonedCheckouts released it after 24h, or staff manually voided the underlying transaction (POST /payments/:txnId/void, owner/admin only). resolveMemberAction (member-action.ts) already resolved pending to complete_checkout, anticipating this gap, but nothing consumed it.

Available to every member: rolling a checkout back is half of the decision every member gets (below), so nothing may hide one of the two choices.

The money-safety logic is NOT duplicated here — it lives in PaymentMonitoringService.voidPendingSubscription, the same guard used by the 24h sweep and the owner’s manual void, now with a third caller keyed by subscriptionId (a member sees a stuck subscription card, not a transaction id). A row is only released when it is still pending, has no settled transaction (completed/refund_pending/refunded), and has no in-flight charge (chargeStartedAt set on a still-pending transaction). A refusal from voidPendingSubscription surfaces to the client as 409 CANCEL_PENDING_UNSAFE.

Cancelling stamps the same cancellationReason = 'abandoned_checkout' marker the sweep uses, so a late webhook payment still revives the subscription via WebhookProcessingService.handlePaymentCompleted’s revivingAbandonedCheckout path instead of silently taking the member’s money for nothing.

The decision: resume or roll back

Cancelling was only half the answer. A member who closed the hosted payment page was told “cancelled, try again from the shop” and left holding a ghost “complete payment” membership; on the other side, memberAction = 'complete_checkout' had no UI on web at all and on mobile was wired to POST /subscriptions/:id/renew, which 400s for pending. Both halves are now real endpoints, and the member is asked which one they want:

ChoiceEndpointWhat it does
Finish payingPOST /subscriptions/my/:id/resume-checkout (409 checkout_not_resumable if a payment already settled or a charge is in flight — a second page would invite paying twice)PlansService.resumeCheckout loads the row, checks ownership and status = 'pending', then delegates to purchase({ resumeSubscriptionId }). Deliberately a thin front door rather than its own implementation: the presale/tokenOnly decision, intro re-pricing, the compliance-form gate, plan availability, seat caps and the ADR-0017 external-link branch all have to hold identically, and a second copy of them drifts. Re-running those gates is the point — a plan that has since sold out or grown a signing requirement is not resumable into.
Drop the purchasePOST /subscriptions/my/:id/cancel-pending { intent: 'regret', source }Same guard and same abandoned_checkout reason as always, plus cancellation_requested_by = the member.

What intent: 'regret' actually changes: the row is filtered out of GET /subscriptions/my (isMemberRolledBackCheckout in subscriptions.service.ts). It is NOT deleted — staff still see it on the member page, reconciliation still has something for a stale webhook to land against, and revival still works. The filter keys on the member being the filer, so the two other writers of the same reason stay visible: the 24h sweep (no filer) and the staff dismiss (a staff filer). A membership disappearing without the member asking is worse than one that lingers.

checkoutReleasedBy ('member' | 'staff' | 'sweep' | null) rides on every subscription in the two list endpoints. displayStatus: 'checkout_abandoned' covers three different sentences — “you cancelled this purchase”, “the gym dismissed it”, “it timed out” — and the only thing separating them is cancellation_requested_by, which is meaningless without the subscription OWNER’s user id to compare against. A staff dashboard doesn’t have that id in scope, and a member client would have to re-derive a rule the server already applies when it hides these rows. So the API answers it. null on any row that isn’t an abandoned checkout; OMITTED (not null) by serializers without the owner in scope, so an absent field reads as “not resolved here”.

Where the intent is stored: on the abandoned charge attempt’s payment_transactions.metadata.checkoutCancel ({ intent, source, by, at }, merged into whatever the checkout path already wrote). subscriptions has no metadata column, and the two that could have held it (cancellation_reason, cancellation_notice_channel) both mean something else that other code reads. The reason string is deliberately left alone — inventing a second one would break revivingAbandonedCheckout.

memberActions (resolveMemberActions, member-action.ts) is the authoritative list of what the member may do, replacing the single memberAction which could only ever name one of a pending row’s two real choices. pending → ['complete_checkout','cancel_pending'], scheduled → ['withdraw_scheduled'], past_due → ['renew'], debt → ['pay_debt','update_card', …cancellation actions], cancelled for non-payment with debt → ['pay_debt','update_card'] (never on the cash rail or for externally billed plans), everything else []. An action appears only when the endpoint will accept it — the same discipline canMemberRenew enforces for renew. The legacy singular memberAction still answers for deployed mobile builds and resolves pending → 'cancel_pending' (the way OUT is the one action worth naming when a client can only render one, since complete_checkout is exactly the value those builds mishandled).

Withdraw a presale membership (member self-serve)

POST /subscriptions/my/:id/withdraw (subscriptions.service.ts, memberWithdrawScheduled). Unflagged.

A scheduled subscription is FIT-287’s presale: sold before the gym opens, card tokenised, nothing charged, no day of access conferred. It is not a membership yet. The retired period-end path accepted these rows and stamped a one-month effective date, but sweepDueCancellations selected only live statuses. The row stayed scheduled + cancelAtPeriodEnd forever, holding a capped presale seat. memberRequestCancellation now rejects scheduled; the dedicated withdrawal action is the member surface on web and mobile.

Withdrawing is the honest replacement: one transaction sets status='cancelled', cancellation_reason='presale_withdrawn', cancellation_requested_by/at, cancelled_at, clears cancelAtPeriodEnd and cancellation_effective_at, and cancels any pending transactions. The seat is released because seatStatusCondition stops counting a cancelled row, and the stored card is left alone. No notice period, no refund, no cancellation form, no review task, no email — every one of those artifacts would assert something untrue about a membership that never started. (Revisit the email if a presale-specific template is ever published.)

The tokenisation transaction is explicitly NOT “money moved”. The presale hosted page records a completed, type: 'charge' row for its ₪1 CreateTokenOnly card validation, indistinguishable from a real sale after the fact — same type, and the amount is a coincidence. PaymentService.createHostedPayment marks it at creation time (metadata.tokenOnly), the only moment the distinction is knowable, and the guard skips it. Without that marker the settled-charge refusal below would fire on every single presale withdrawal, i.e. exactly the rows the feature exists for.

Three refusals separate “nothing has happened yet” from “something has”:

SituationResponse
Status is active/paused/past_due/debt409 withdraw_requires_cancellation — the membership started; the member is entitled to the notice period, which staff record on the dashboard.
A completed/refund_pending/refunded transaction exists409 withdraw_requires_cancellation — money moved, so ending this has refund math attached.
A pending transaction has charge_started_at409 withdraw_charge_in_flight — outcome unknown, money may already have moved. Same paranoia as voidPendingSubscription.
Status is pending or cancelled400 — a different kind of row entirely; the lever there is cancel-pending or resume-checkout.

memberRequestCancellation refuses scheduled outright with 409 use_withdraw so the dead end cannot recur. A staff business termination can still handle a scheduled row cleanly, with no refund math (nothing was charged) and the seat released on the status flip.

The cancellation cron carries a repair pass (repairStuckScheduledCancellations) that withdraws every row already stuck. It runs the SAME money-safety guard as the member endpoint, re-evaluated inside the transaction under the row lock: a stuck flag is not licence to close a membership somebody has since paid for, or one whose charge is still in flight. Blocked rows are left exactly as they are and emit payment.presale_withdraw_skipped — a background job declining to act must not look identical to one that never ran. The repair preserves the member’s ORIGINAL cancellation_requested_at/by (nobody acted today), never cancels a transaction carrying charge_started_at, and is deliberately not gated on the effective date: that date was fabricated by a flow that should have refused the request, so making these members wait it out would be honouring the bug. The whole pass is wrapped in a catch — a stranded presale must never stop a matured cancellation from taking effect.

Punch cards and drop-ins are not cancelled

memberRequestCancellation also refuses any plan whose type is not subscription — a punch card (class_pack) or a drop-in — with 409 cancel_unsupported_plan_type, carrying the offending planType in the body.

Israeli consumer law does not treat these as continuous transactions. The window is 14 days from the purchase or the written document (whichever is later), and a booked session must be cancelled at least 2 days beforehand — docs/reference/israeli-cancellation-rules.md §3 — whereas §4’s one calendar month applies to a continuing membership. computeCancellationEffectiveAt implements only the §4 rule and applies it to whatever it is handed, so a member cancelling a 10-session card was given a fabricated month’s notice period, an effective date with no legal basis, and (once the org publishes a template) a signed document asserting all of it.

Until the non-continuous rule is built this flow does not accept them, and refusing costs the member nothing they had: a card is not cancelled, it runs out — credits stay usable to the last one — and money back on an unused card is a conversation with the gym, which is what §3’s window would formalise. Both clients hide the affordance and, wherever pack rows render, show one muted line under them in its place: profile.holdings.packsRunOut (“Punch cards and drop-ins aren’t cancelled; they simply run out”). Web shows it on the member payments page; mobile shows it on Payments under the pack rows, where the cancel row is only ever offered for kind subscription. The 409 is the backstop for a stale build: mobile’s cancellation-request screen shows payments.errorCodes.cancel_unsupported_plan_type for it. Staff keep the immediate cancel (POST /subscriptions/:id/cancel), which ends a row administratively and computes no notice period, so a gym honouring a refund by hand is not blocked.

course plans never reach here at all: PlansService.purchase refuses them outright, so no course subscription row exists to cancel.

Holding kind on subscription responses

Every subscription response with the plan in scope says what kind of holding it is, so no client re-derives it from plan.type or reads currentPeriodEnd (a billing boundary on a membership, a validity date on a pack) as both. Emitted by /subscriptions/my, the guardian members/:memberUserId/my-subscriptions, both pay-debt responses, the staff members/:membershipId/subscriptions, the plan purchase response, the plan subscriber list and the presale swap response. All fields are optional in the shared schema (additive); currentPeriodEnd keeps its value.

FieldMeaning
kindThe plan’s type: subscription, class_pack, drop_in, course.
validUntilPacks and drop-ins: currentPeriodEnd, or null when the plan has no expiry. Always null for subscriptions and courses.
billingState: 'consumable'A live pack or drop-in (active / past_due / scheduled / debt). Replaces only recurring for those kinds: a cash pack stays prepaid, a paused one paused, a cancelled one stopped. nextChargeAt is null.
consumableStatusPacks and drop-ins only, else null. used_up when the ledger balance is 0 and either a spend took it there (lastSpentAt present) or the row is still not cancelled. A cancelled pack zeroed by its own cancel — a staff refund or desk cancel resets the ledger, closeLedger — was ended, not used up: it reads expired. Otherwise expired when the row ended (the lifecycle job’s expired, or ended early) or is still active past its currentPeriodEnd; otherwise available (a pending checkout included).
consumableEndedAtNever null for an ended pack. used_up: when the last credit was spent (EntitlementBalanceService.lastSpentAt — the newest balance-moving ledger row, if it is a negative debit/adjustment; read only for packs at 0), else updatedAt. expired: currentPeriodEnd for reason expired, else cancelledAt; falling back to the other, then updatedAt. Null while available.

Resolved in holding-state.ts (resolveHoldingFields).

Buy again, never renew. A pack runs out; the member’s way back is the shop. No renew in memberActions for a pack and renewSubscription refuses it (see Renew).

Member surfaces partition holdings with one shared rule, partitionHoldings(subs, now) in @taikan/shared (libs/shared/src/lib/holdings.ts). A row’s kind is holdingKind(sub) — sub.kind ?? sub.plan?.type ?? null — the one adapter allowed to read plan.type, so a client deployed ahead of an API that sends no kind still never promotes a pack to the primary card. Every client kind check (web and mobile) goes through it:

  • primary: the newest live membership (active / paused / past_due / debt), else a plan cancelled for non-payment that still owes, else a presale (scheduled), else an unfinished checkout (pending), else the newest cancelled plan whose memberActions include renew (one the member ended — the card that carries Renew). Null when the member holds only packs. Every non-pack kind is a candidate.
  • otherRecurring: the remaining candidates, same order — compact rows with payment-critical actions only. Renewable ended plans never get a row: they surface only as the primary fallback.
  • packs: available packs and drop-ins (newest first), then used_up / expired ones whose consumableEndedAt is within 30 days, most recently ended first. An ended pack with no consumableEndedAt (the API always sends one) is kept, as if it had just ended, rather than silently dropped.
  • Plan-change lineage (cancellationReason = 'plan_change'), abandoned checkouts and withdrawn presales are never holdings.

Dictionary keys (en/he/ru): plans.kindNoun.<kind> and profile.holdings.* — packsTitle, otherPlans, credits (plural on {count}, with {total}), dropInEntry, dropInUsed, validUntil ({date}), noExpiry, usedUp, expired, buyAgain, browsePlans, packsRunOut (the muted line under the pack rows: punch cards and drop-ins aren’t cancelled, they run out).

Web member surfaces

Profile (profile/page.tsx), Payments (profile/payments/page.tsx) and the guardian’s child summary (ChildPlanSummary, profile and schedule while acting for a child) all draw partitionHoldings through MemberHoldings (components/member/holdings/):

  • Primary card — the page’s own card, with the kind noun above the plan name. Payments keeps every banner and action it had (debt, unpaid term, notice and “I want to stay”, scheduled plan change, early renew, cash coverage, external-billing hint). “Next billing” is nextChargeAt and only appears while one is scheduled; the price shows / interval only when the plan has one. Change plan and the “Unlimited” label read kind. Profile keeps renew on this card only.
  • Other memberships — compact rows: name, kind, status, next charge, and only complete_checkout / cancel_pending / withdraw_scheduled. A debt on one is paid from the page’s debt banner.
  • Pack rows — name, kind, credits left (“7 of 10 left”; a drop-in reads “1 entry” / “Used”), “Valid until {date}” or no expiry while in use, a used-up / expired badge once finished, and Buy again: /shop?plan=<id>&src=buy_again while the plan is still sold (isActive, shown in shop), else /shop; the tap captures member_buy_again_clicked { org_id, plan_id, kind, consumable_status, on_sale } and the Shop landing tags member_shop_deeplink with entry: 'buy_again' | 'link' from src; &for=<childUserId> when it is a child’s card (the Shop seeds its recipient from ?for=). Rows keep complete_checkout, cancel_pending, resume_cancel, withdraw_scheduled; never renew. One muted profile.holdings.packsRunOut line sits under the rows.
  • Packs only — the rows plus a browse-plans link; no membership card. Nothing at all — Profile’s “no active plan” card.
  • A cancelled membership that owes nothing is not a holding, so a lapsed member’s way back on web is the shop.

Kind-aware purchase copy: the payment-return success text reads kind from the polled subscription (shop.paymentReturn.successDescByKind.<kind>, and managedMembers.actingFor.planActiveForByKind.<kind> when buying for a child); the timeout, external-pending and kind-less cases keep the generic “your plan” copy (successDesc, planActiveFor). The Shop’s instant (free) purchase toast is shop.planCard.purchaseSuccessByKind.<kind> from the purchase response. Staff see the kind noun beside each plan name on the member’s Memberships tab. An ended pack there also carries a neutral pill from consumableStatus + consumableEndedAt: “Used up · {date}” (a drop-in “Used · {date}”) or “Expired · {date}”, reusing profile.holdings.usedUp / dropInUsed / expired.

Renewal cron

RecurringChargeService.handleRecurringCharges — daily 02:00 UTC (recurring-charge.service.ts).

See payments/behavior.md for the full flow. Subscription-side outcomes:

OutcomeSub updateMembership updateNotice
Successstatus=active, period advanced, attempts=0, credits refilledpayment_status='current'none (receipt only via initial charge path)
Success on a debt row (retry)debt cleared (debtClearedPatch), period unchanged, nextChargeDate = current_period_endrecomputed (current unless another plan owes)—
First declinestatus=debt, debt_reason='charge_failed', debt_amount_in_cents = agreed price, debt_since=now, period = the missed period (credits refilled), nextChargeDate = first f(N) retrypayment_status='debt'debt-opened (via SUBSCRIPTION_DEBT_OPENED)
Declined retryattempts++, nextChargeDate = next retry or null; debt unchanged—paymentFailedEmail

Day N, still unpaid, is the subscription lifecycle’s job. Rules: payments › Unpaid payment.

A3 exception to the “Success” row above: a row that went through the boundary swap (below) THIS tick does not get its credits refilled to the raw allotment — applyInPlace already set remainingCredits to the allotment minus whatever it deducted for kept confirmed bookings, in the same transaction as the swap, and refilling on top would silently erase those deductions (a banked-quota hole). handleChargeSuccess detects this via the swap_applied marker applyScheduledSwap stamps on the row it returns.

A5 — advisory lock (FIT-254 review Wave A): FOR UPDATE OF s SKIP LOCKED alone does not prevent two overlapping invocations of the whole sweep from both charging the same due subscriptions (e.g. the scheduled tick racing the test-only manual-trigger endpoint) — those row locks release the instant each row’s own implicit transaction ends, not for the duration of the whole method. runRecurringCharges now wraps the entire sweep in a Postgres session advisory lock (pg_try_advisory_lock/pg_advisory_unlock, a fixed key derived from a literal string via a 32-bit FNV-1a hash, computed once in recurring-charge.service.ts) — a run that can’t acquire the lock logs a warning and returns immediately, no dueSubs SELECT, no charges.

Scheduled plan-change boundary swap (PD-B5a, FIT-254 §4.4): the selector picks up subs with scheduled_plan_id IS NOT NULL even when payment_method_id IS NULL (comped/free subs are otherwise invisible to this cron and their schedule would never apply — PD-B5b). A2(b) fix: the original next_charge_date <= now predicate alone could never actually select such a row — a card-less sub’s next_charge_date stays NULL forever, so its schedule would sit unapplied past its own boundary indefinitely. The WHERE clause now ORs in a second arm: scheduled_plan_id IS NOT NULL AND next_charge_date IS NULL AND current_period_end <= now, which selects it once its own period has actually crossed the boundary.

Before any charge attempt, processCharge calls applyScheduledSwap: in one serializable-isolation transaction (A4 — previously no isolation level was requested despite the withSerializableRetry wrapper), swap plan_id to the scheduled plan, reset remaining_credits to its allotment, clear scheduled_plan_id/plan_change_scheduled_at, and run the booking sweep (applyInPlace — see Booking-entitlement sweep below; inseparable from the plan change). The row is then mutated in-memory so the rest of processCharge (the charge attempt, handleChargeSuccess’s period/credit reset) transparently bills and renews on the new plan at the price committed when the change was scheduled (scheduled_plan_price_in_cents / scheduled_plan_currency, ADR-0019; the live plan price is the fallback only when that snapshot is null) — swap-first-then-bill, one if before the existing charge path, no parallel billing state machine. Charge failure after the swap follows the normal past_due/debt machinery on the new plan (the member/staff consented to it at scheduling time). Emits payment.plan_changed { mode: 'scheduled', … } and the consolidated sweep email (sendPlanChangeSwept).

A4 — TOCTOU guard: the swap UPDATE is now conditional (WHERE id = ... AND scheduled_plan_id = <what the SELECT saw> AND status IN ('active','past_due','debt'), .returning()) instead of unconditional. If the schedule was cancelled or the sub’s status changed between the SELECT and this tick, the guard matches zero rows and the row is skipped entirely for this tick (no sweep, no charge) — the next cron run re-selects and re-decides from scratch. Chosen over re-reading to fall back to an unswapped charge, which risks billing the OLD plan’s price a tick late instead — not safer, and considerably more code for a rare race.

A2(a) — comped subs are never billed via an unrelated card: the original code called paymentMethodService.getActivePaymentMethod(membershipId) unconditionally after the swap — for a comped sub (payment_method_id IS NULL on the subscription itself), this is membership-scoped, so if the same membership had another (paid, non-comped) subscription with an active card, that unrelated card got billed for the comped sub’s plan. Fixed: the charge decision is now driven by the subscription’s own payment_method_id column, checked BEFORE the membership-wide lookup is even consulted. A card-less sub after the swap is a swap-only visit: the swap transaction itself advances currentPeriodStart/currentPeriodEnd (boundary + new plan’s interval) and leaves nextChargeDate NULL — the sub stays outside the billing cron until an admin explicitly activates billing on it (a separate action). No charge attempt, no failure-counter changes. A card-less sub with no schedule is unaffected (still excluded by the SELECT’s payment_method_id IS NOT NULL OR scheduled_plan_id IS NOT NULL clause, as before).

A8 — promotion emails deferred past commit: applyInPlace runs WITH the swap’s own transaction (tx passed) — any waitlist promotions it made are withheld from announcement (see Notifications below) and returned as pendingPromotions on the result instead. applyScheduledSwap calls sweepEngine.sendPendingPromotionEmails(sweepResult) immediately after the swap transaction has committed.

Subscription lifecycle

SubscriptionLifecycleService.tick (subscription-lifecycle.service.ts), hourly at :05, owns every calendar-driven transition. Per org (not deleted) it runs one pass per org-local day: skipped when organizations.billing_lifecycle_day is already today, run under the advisory lock taikan:lifecycle:<orgId>, and the day is stamped only when every row in the pass succeeded, so a failed row is retried on the next tick. Each transition is a guarded UPDATE … WHERE status = <expected>, so a re-run is a no-op. Decisions are the pure lifecycleTransition(row, policy, today, now). Rows with a pending transaction on the subscription, or a pending protected-billing transaction on the membership, are skipped; frozen (paused) rows are never selected.

RowTransition
debt, cancellation instant (debtCancelsAt(debt_since)) passed, no retry still duecancelled for non-payment (endSubscription('non_payment')), then the cancellation notice
debt, today is the pay-by day, N ≥ 2last-day notice
active, payment_due_at passed with an unpaid amountin debt, payment_due, day 0 = payment_due_at, period kept
active class pack / drop-in past current_period_endended, reason expired
active cash subscription, cash_prepaid_through passed, coverage rows exist and the agreed price > 0in debt, cash_lapsed, day 0 = cash_prepaid_through, period [cash_prepaid_through, +interval]
same, but no coverage row ever recorded (a gift) or agreed price 0ended, reason gift_ended; open cash renewal / payment-due tasks completed
active card subscription, period over, no next_charge_date, agreed price 0period rolled to the current one (credits refilled, new quota cycle)
same, agreed price > 0, no card on fileended, reason term_ended

Natural ends skip rows with scheduled_plan_id, cancel_at_period_end or an unpaid term still running; a card plan the charge jobs still renew (next_charge_date set, or a card on file) is never ended. SubscriptionsService.endSubscription writes status='cancelled', cancelled_at, cancellation_reason, next_charge_date=null (and retention_outcome='lost_payment' for non_payment when a retention case is open), leaves debt columns and notice fields alone, then sends the observability event (source: 'lifecycle'), the churn event with the reason, the SUBSCRIPTION_CANCELLED audit row (mode: 'lifecycle'), the booking sweep (payment_issue for non_payment) and stops the provider standing order on card rows.

Cancellation cron

CancellationCronService (apps/api/src/subscriptions/cancellation-cron.service.ts) — daily 02:30 UTC, runs after the renewal cron so a sub that’s scheduled to cancel and also renewing the same morning isn’t double-handled.

sweepDueCancellations() first runs the repair pass: status='scheduled' AND cancelAtPeriodEnd=true → withdrawn outright (presale_withdrawn) — see "Withdraw a presale membership". Not gated on the effective date, and never allowed to throw. then picks rows WHERE cancelAtPeriodEnd=true AND status IN ('active','paused','past_due','debt') AND cancellationEffectiveAt <= now flips each to status='cancelled', cancelledAt=now settles against the stored effective date and fires membershipEnded with the real refund state stops the provider's own standing order (cancelProviderStandingOrder, shared with the staff immediate-cancel path — opens an external-cancel reminder task + emits payment.provider_cancel_failed on failure, non-blocking) closes the retention case if one is open (RetentionCaseService.closeOnEffective — see "Retention case" above) and emits cancellation.effective emits payment.subscription_cancelled { source: 'period_end_cron' } runs the booking-entitlement sweep (C4) as of the cancellation's effective date (not "now") — subscriptions.service.ts, sweepOnEnd

Once every row is processed, emits a per-run summary: cancellation.sweep_completed { due, ended, failed, durationMs }.

Keyed on cancellationEffectiveAt — the date computed once from the member’s notice — not on currentPeriodEnd, which is a billing boundary that moves on every renewal and used to push scheduled cancellations out of this sweep’s reach permanently. Every live status is swept, not just active: a member who gave notice while paused or in debt is just as entitled to have it take effect.

How a terminal row is LABELLED (displayStatus)

status='cancelled' collapses four unrelated endings, so the API resolves a separate displayStatus for clients (subscription-display-status.ts; clients render it via subscriptionLabelKey and never re-derive a label from raw columns):

RowdisplayStatus
cancellation_reason='presale_withdrawn' — a presale the member dropped before it startedwithdrawn
cancellation_requested_at set — staff immediate cancel, or a member notice this cron has since maturedcancelled
Never activated and nobody filed anything — the abandoned-checkout sweeps, the dashboard void, a presale whose first charge never cleared (presale_first_charge_failed)checkout_abandoned
cancellation_reason='non_payment' — cancelled by the lifecycle at the end of the grace periodcancelled
cancellation_reason='plan_change' — superseded by a plan changeexpired
cancellation_reason expired / gift_ended / term_ended — a lifecycle calendar endexpired
Everything else: provider-side cancellation, account erasureexpired

The withdrawn arm is checked FIRST and the order is load-bearing: a withdrawal stamps cancellation_requested_at, so without its own arm it would read as a deliberate cancellation — telling a gym a founding member “cancelled” implies a membership that existed and a refund conversation that cannot apply, since nothing was ever charged. A member-rolled-back checkout keeps reading checkout_abandoned for the same reason in reverse: the rollback stamps only cancellation_requested_by, never the timestamp.

The cancelled / expired split is keyed on cancellation_requested_at, the one column only the two deliberate paths stamp. Before it existed, a subscription staff cancelled this morning read to the gym as “פג תוקף / expired” — the calendar’s doing rather than their own.

Retention case

A member request is not yet a cancellation notice. It cannot stop billing or establish an effective date. It creates an owner obligation that staff must either resolve as a save or issue as the member’s legal cancellation. The org cannot use the staff step to replace the original timestamp/reason or switch the request into an immediate cancellation. There is no separate cancellation_requests table or approve/reject state machine; request and notice state live on the subscription and its single retention task.

RetentionCaseService (apps/api/src/subscriptions/retention-case.service.ts + retention-case.controller.ts) owns the case that opens against a subscription when a member requests cancellation, staff issue a notice, a member withdraws a presale, or an opening-day charge fails. Cases and their staff notifications are always-on under the same eligibility and idempotency rules.

What opens or promotes a case

TriggerKind (retentionCaseKind)Behavior
Member mobile request (memberRequestCancellation)requestUrgent cancellation_review task due organizations.retention_sla_hours (default 48) from the request timestamp, assigned to the active owner and linked to the member. Description carries the required reason code and optional explanation. Any published cancellation form is issued for immediate signing; no cancellation/end date exists yet.
Staff issuance (issueMemberCancellation)noticePromotes the same request task in place when present; otherwise opens a notice case for an offline request. Stores the legal notice/effective date and starts existing escalation, digest, notification, and settlement behavior. Reuses an unsigned request form and does not create another when that request form was already signed.
Presale withdrawal (memberWithdrawScheduled / repair pass)presale_withdrawalImmediate staff email plus a low-priority owner task with no due date. No cancellation notice or form.
Presale opening-day charge fails (handlePresaleChargeFailure)presale_charge_failedUrgent owner-assigned payment_follow_up task; retry/final member notifications remain unchanged.

Every case emits its matching cancellation.* event and stamps retentionCaseKind, retentionCaseTaskId and retentionOwnerId. openForNotice reuses a request task instead of creating a second obligation.

The presale withdrawal member email (presaleWithdrawnForMember) confirms the spot was released and nothing was charged.

A request after a closed save starts clean. memberRequestCancellation clears prior retention outcome/acknowledgement/contact state and opens a new request case. Staff issuance then promotes that task to notice without resetting the original request timestamp, reason, owner, or task id. A direct offline notice also starts a clean notice case.

Working a case

  1. Acknowledge — POST …/subscriptions/:id/retention/acknowledge (owner/admin, subscriptions:manage). Sets retentionAcknowledgedAt if unset; idempotent. Emits cancellation.case_acknowledged { hours_since_notice }.
  2. Contact — POST …/retention/contact { channel, note? } (channel: phone | whatsapp | email | in_person | other — distinct from cancellationNoticeChannel, since a retention call isn’t bound to the §5-mandated legal-notice channel list). Sets retentionContactedAt on the first call and always increments retentionContactAttempts. Each attempt also persists its channel, note, actor, timestamp and sequence number in the append-only audit trail. Emits cancellation.case_contacted { attempt, contact_channel, hours_to_first_contact }.
  3. Outcome — POST …/retention/outcome { outcome, note? }. Restricted to saved | saved_change_plan | saved_paused | saved_discount | saved_quota; lost_* and rebought are system-set only. An open request (retentionCaseKind='request' and retentionOutcome=null) or live issued notice is cleared in the same action, including unsigned paperwork. A stale cancellation flag on an already-ended subscription returns 400 “not scheduled” before any write, preserving the historical notice. Selecting saved_discount in the staff panel opens the real temporary-pricing mutation rather than recording an outcome without changing money; after the managed-provider price save succeeds, the outcome closes the case.
  4. Stay / withdraw — the member taps I want to stay, the resume_cancel action, through POST …/subscriptions/my/:id/resume. This works immediately after a request and after staff issuance, while status is active | paused | past_due | debt, for every provider. Web and mobile place the action prominently on the current-subscription card. It clears cancellation request/notice fields, completes the task, stamps retentionOutcome='saved' if unset, and revokes unsigned paperwork through FormsService.revokeCancellationForm; signed documents remain immutable. Billing provider state is not changed.

GET …/retention/activity reads the current case’s chronological, append-only activity ledger from audit_events: acknowledgement, every contact attempt, a recorded outcome, and a notice withdrawn on the member’s word. Each row carries the action kind, timestamp, actor type, stable actor user id and current actor name, plus the action’s channel, note, attempt number, or structured retention outcome when applicable. The current retention task id is stored on new events and used as the exact case boundary; task creation time admits older rows that predate that metadata without leaking a prior case. This is the shared source for the staff CRM timeline and retention reporting, including whether the member stayed and which save outcome was responsible.

The five staff endpoints live on RetentionCaseController under organizations/:orgId/subscriptions/:id/, role-gated on subscriptions/manage; the member resume endpoint lives on SubscriptionsController. Every mutation is audited.

Closing a case

How it closesWhat happens
Staff sets a saved* outcome, or staff withdraws on the member’s wordSee “Working a case” above; staff withdrawal emits cancellation.notice_withdrawn { withdrawn_by: 'staff_on_behalf' }.
Member taps I want to stay during a pending request or live issued noticeRequest/notice fields clear, the task completes, and unsigned paperwork is revoked; outcome becomes saved. Supports every provider without changing billing. Emits cancellation.notice_withdrawn { withdrawn_by: 'member' }.
A member with an open presale_withdrawal case buys back into the orgCloses the LATEST such case on the membership: retentionOutcome='rebought', completes the FYI task when retentionCaseTaskId is set, emits cancellation.presale_rebought { days_to_rebuy }.
The period-end sweep matures the notice (sweepDueCancellations)retentionOutcome = 'lost_contacted' if retentionContactedAt is set, else 'lost_no_contact'. Task completed. Emits cancellation.effective { retention_outcome, contact_attempts }.
Opening-day charge exhausts its retriesretentionOutcome='lost_payment', set directly by handlePresaleChargeFailure.

Escalation cron

Hourly, CancellationCronService.handleRetentionEscalation (@ScheduledJob('CancellationCronService.handleRetentionEscalation', '15 * * * *'), on the BullMQ scheduler queue per ADR-0020) → RetentionCaseService.runEscalationSweep, wrapped in a Postgres session advisory lock (withAdvisoryLock, key taikan:retention-escalation-cron) so overlapping invocations skip cleanly rather than double-escalating. Candidates are open notice cases (retentionCaseKind='notice', retentionOutcome IS NULL) still live (status IN ('active','paused','past_due','debt')) on non-deleted memberships. Ended cases and deleted memberships are never chased. For every candidate:

  • unacknowledged — the case’s SLA deadline has passed and retentionAcknowledgedAt is still null. The deadline is anchor + organizations.retentionSlaHours (default 48h), where anchor is the LATER of cancellationNoticeAt and the case’s own task createdAt — not the task’s date-only dueDate (display-only, day-precision) and not the notice date alone. The anchor matters because a staff-recorded notice can legitimately be backdated days into the past (onBehalfOfMember.noticeAt, §5) while the case itself only opens the moment staff enter it — anchoring on the notice alone would email every owner/admin “nobody acknowledged this” on the very next hourly tick, for a case nobody has had a chance to see yet.
  • uncontacted_final_week — cancellationEffectiveAt is between now and 7 days out (inclusive) and retentionContactedAt is still null. The lower bound (>= now) keeps a case whose effective date has already passed, but that the daily 02:30 sweep hasn’t yet flipped to cancelled, from firing this stage on every hourly tick until it runs.

Each stage records a RETENTION_CASE_ESCALATED audit row and emits cancellation.case_escalated { stage } — at most once per (subscription, stage). Wave 1 retired the per-event caseEscalatedForStaff email (see Daily digest below): the audit row, the observability event and the per-stage idempotency all stay exactly as Wave 0 built them, only the mail is gone. Idempotency is sourced from the audit log itself: before escalating, the sweep checks for an existing RETENTION_CASE_ESCALATED audit row on that subscription carrying a matching metadata.stage, and skips if one exists. The audit row is written BEFORE any mail could pick the case up, not after — it is the claim wasEscalated checks, and writing it first means a process death (or a runWithRetry retry) between the two can only cost a silently-missed escalation, never a duplicate one. A case can escalate on both stages independently over time, but never twice on the same stage.

Daily digest

Owner/admin mail for the retention family does not fire per event — a notice, an SLA miss or a final-week warning produces no mail of its own. The urgent cancellation_review task and the case panel are the real-time signal; batched staff mail about cases comes from the staff newsletter’s cancellationRequest and retentionCaseAttention events, daily by default. See ../staff-newsletter/.

Reason-code taxonomy

cancellationReasonCode (cancellation_reason_code enum): relocation | financial | dissatisfaction | health | schedule | no_longer_needed | other. Separate from the free-text cancellationReason column, which also doubles as a control marker for abandoned_checkout/plan_change/presale_withdrawn — left alone — so retention analytics can group on a fixed set of values while the member’s own words stay open-ended. The mobile retention request requires the code and requires free text only for other. The staff legal-notice schema keeps both fields optional: a missing justification can never gate a valid notice.

Retention case events

Part of the shared CancellationEvents registry (libs/shared/src/lib/analytics/cancellation-events.ts) — see event-tracking/README.md for the full typed registry including client-side capture: cancellation.notice_given, cancellation.case_acknowledged, cancellation.case_contacted, cancellation.case_escalated, cancellation.offer_accepted, cancellation.notice_withdrawn, cancellation.effective, cancellation.presale_withdrawn, cancellation.presale_rebought, cancellation.presale_charge_failed, cancellation.sweep_completed, plus plan_change.presale_swap (see Presale swap below). cancellation.notice_given is emitted alongside the pre-existing payment.cancellation_scheduled (kept for one release rather than replaced outright). cancellation.offer_made, cancellation.form_issued and cancellation.form_signed are declared in the registry for a later wave (an explicit staff offer short of acceptance, and the written cancellation form’s issue/sign moments) but nothing emits them yet on this branch.

sweepDueCancellations also emits a per-run summary once every row is processed: cancellation.sweep_completed { due, ended, failed, durationMs }.

cancellation.settled records refund outcomes at the effective date and final-tail charge outcomes (mode: 'charge', transaction ID, amount, currency, outcome). See Settlement below.

Settlement at the effective date

Cancellation notice fixes cancellationEffectiveAt at one calendar month from notice and suppresses normal full renewals immediately. Settlement money movements and recovery of already-created payment intents are always-on under the eligibility, locking and idempotency rules below.

Paid coverage is a half-open interval. Settlement has three directions:

Paid-through versus effective dateSettlement
currentPeriodEnd < cancellationEffectiveAtOne final managed-payment charge for [currentPeriodEnd, cancellationEffectiveAt), never a full renewal followed by a refund.
EqualNo charge or refund.
currentPeriodEnd > cancellationEffectiveAtProportional unused prepaid refund, using the existing computeCancellationRefund and refund routing below.

Final tail. CancellationTailSettlementService.run runs before ordinary 02:00 recurring billing and as a catch-up before the cancellation sweep ends rows. Collection starts when paid coverage ends, not on notice day. The amount is floor(actualPaidCents × tailMilliseconds / paidIntervalMilliseconds), using the actual stored currentPeriodStart/currentPeriodEnd and the current plan’s completed coverage payment (not token validation, debt collection, plan-change proration or a prior tail). Multiplication/division uses integer arithmetic and rounds down once to whole cents; it does not round a daily rate first. For notice on September 5, paid coverage August 26–September 26, and effective October 5, the tail is nine days against a 31-day paid interval. A 31,000-cent period produces a 9,000-cent final charge; a 10,000-cent period produces 2,903.

The saved method must be the subscription’s own active method, match its payment provider, and use the existing verified token-charge provider path. Manual/off-platform or externally billed payments are never charged by Taikan; the organization remains responsible. Missing verifiable paid coverage or a usable matching saved method is not permission to guess a price or use another subscription’s card.

On verified success, only currentPeriodEnd and nextChargeDate extend to the effective date. Credits, quota anchor, current-period start and intro-cycle counter are unchanged, and no renewal event is emitted. Access and booking horizon remain through cancellationEffectiveAt, independently of payment coverage; C3 already uses that date while notice is scheduled. A failure never postpones cancellation or grants another quota.

Failure and recovery. A verified decline records a failed recurring transaction, increments failure count, accrues only the tail amount once as debt and clears nextChargeDate. A row with no debt enters debt (charge_failed, no retries, day 0 = the decline) and its period extends to the effective date; a row already in debt adds the tail amount and keeps its day 0. A localized owner-assigned review task links the member, exact amount, date and transaction. Transport errors (including adapter-returned failures without a provider response) remain pending with an unknown-outcome review task: money may have moved. There is no automatic second charge after a tail intent is claimed, and never a fallback full renewal. Staff must verify the provider outcome before collection. The normal debt-clear flow is not invoked by this final settlement; the period-end sweep or the lifecycle’s day N ends the row, whichever comes first.

The unique payment-DAL key is cancellation-tail:<subscriptionId>:<effectiveAt ISO>. It is shared across overlapping runs and never includes retry count. The existing settlement audit action and per-subscription advisory lock prevent conflicting refund/charge directions for the same effective date. Payment outcome application and its tailOutcomeApplied metadata marker commit together under transaction locks, so recovery can repair coverage or debt without another gateway call. Exact verified webhooks enter this path before ordinary activation/renewal, including after cancellation; they cannot resurrect the row or refill quota. Contended webhooks fail for retry rather than being acknowledged without recording the outcome. Withdrawal of notice uses the same lock and refuses unresolved tail payments; once paid coverage is applied, withdrawal can resume normal billing from that coverage boundary without overlapping the tail.

Prepaid refund. computeCancellationRefund (apps/api/src/subscriptions/cancellation-math.ts) is the same calculation shown by previewCancellation, evaluated at the stored effective date rather than sweep time. Its existing proportional refund behavior is unchanged.

Who moves the money is RefundCapabilityService.resolveExecution(orgId), the same signal the staff refund affordances use:

resolveExecutionWhat settlement does
automaticRefunds refundableInCents against the paying charge through PaymentService.refund.
taskThe same call, which opens a manual_refund task — already chased by RefundTaskSweepService when it goes overdue. The member is told the refund is on its way, not done.
unavailable (includes ADR-0017 externally billed)No refund attempt: Taikan does not hold the money. Opens a localized owner-assigned task naming member, plan, amount and effective date.

One member email. The sweep already sent membershipEnded; it now carries the real refundIssued/refundManual/refundAmountInCents instead of hardcoded false. PaymentService.refund’s own receipt is suppressed for this caller via notifyMember: false (additive on RefundRequest, default true, so no other caller changes), because one event must not produce two emails — and the “membership ended” mail is the one that explains WHY the money moved.

Refund idempotency uses the paying charge’s own refund_pending/refunded status, an audit claim (subscription.cancellation_settled) scoped to subscription and effective date before the provider call, and the same per-subscription advisory lock. A refund that throws after the claim is not automatically retried; the owner gets a review task rather than risking a second money movement.

A settlement failure never blocks the sweep’s own job: the membership still ends, and the rest of the batch still matures.

Plan change (FIT-254 §4)

Replacing the plan behind one specific subscription. subscriptionId is always explicit — multi-sub members exist, so “change the member’s plan” is not a valid mental model.

Eligibility (§4.1)

Applies to type='subscription' plans only, both source and target (class packs/drop-ins are consumables — buy another; courses have their own entitlement flow). Target must be isActive=true, same org, a different plan id than current. Changing to a plan the membership already holds in a LIVE status (active/paused/past_due/debt) → 409 Already subscribed to this plan (existing per-plan exclusivity). B4 fix (FIT-254 review Wave B): this exclusivity check previously also counted pending — an abandoned/in-flight checkout on the target plan, not a real conflict — which bricked retries (the member’s own retry would 409 against their own stale pending row instead of reaching PlansService.purchase’s pending-reuse logic). Narrowed to the four live statuses; pending and cancelled are both excluded now.

Enforced by the single shared function enforcePlanChangeEligibility (plan-change-eligibility.ts:38) — called by preview, org-change, member self-serve change, and PlansService.purchase’s replacesSubscriptionId validation, so preview and apply can never disagree:

Old sub stateImmediate changeScheduled (period-end) changeError code
active✅✅—
active + cancel_at_period_end=true (a pending cancellation notice)✅ (implicitly clears the flag — the member chose to stay, on a different plan)❌pending_action_conflict (scheduled only)
active + existing scheduled change✅ (clears the schedule)❌pending_action_conflict (scheduled only — cancel the pending change first)
scheduled (presale)See Presale swap below — its own mode, not this matrix.n/apresale_swap_requires_same_group if the target plan isn’t in the same planGroupId
past_due❌❌outstanding_balance
debt❌❌outstanding_balance
paused❌❌resume_first
pending❌❌plain 400 “checkout still pending”
cancelled❌❌plain 400 “already cancelled”

Presale swap (scheduled row, same plan group)

A scheduled subscription (FIT-287 presale: sold, card tokenised, nothing charged, no access conferred) can move to another plan in its OWN planGroupId, applied on the SAME row — no checkout, no proration, nothing charged now, no new subscription id. Preserves the seat, buyer order (PR #325’s subscriptions.created_at ordering), payment token, and opening day exactly, because all four depend on this being the same row. A group-less presale plan has nothing to swap to.

  • Eligibility: enforcePlanChangeEligibility special-cases status === 'scheduled' before any of the live-sub refusals — its only question is newPlan.planGroupId === sub.plan.planGroupId (both non-null). A mismatch (including a group-less target) refuses with 409 PRESALE_SWAP_REQUIRES_SAME_GROUP: “a different offer has no guaranteed seat — withdraw this membership and buy the new plan instead.”
  • Apply (PlanChangeService.applyPresaleSwap): per-subscription advisory lock via withAdvisoryLock (pinned to one dedicated pool connection — see advisory-lock.ts), reload + re-verify status and group match under the lock, refuse if a charge is in flight (409 PLAN_CHANGE_CHARGE_IN_FLIGHT — mirrors findWithdrawBlocker’s in-flight check, since nothing settled can exist yet on a presale row), refuse a member-initiated (non-staff) swap onto a plan not offered for self-purchase (showInShop=false, same predicate/error as PlansService.purchase’s hidden-plan gate — staff keep desk access). Inside ONE db.transaction: check the TARGET VARIANT’s own seat cap only (never the group cap — the member already holds a group seat, so re-checking the group would count them against themselves; run against the transaction’s own executor, since outside a transaction the seat check’s row lock is merely advisory), then a single guarded UPDATE subscriptions SET plan_id=..., negotiated_price_in_cents=NULL, ... WHERE id=... AND status='scheduled' — a desk price negotiated for the OLD variant is cleared, never carried to the NEW one. Audits SUBSCRIPTION_PLAN_CHANGE_APPLIED (before/after include negotiatedPriceInCents) with metadata.mode: 'presale_swap'; emits plan_change.presale_swap { fromPlanId, toPlanId, daysToOpening }. Re-sends the presale confirmation email with the new plan via the same membershipWelcome.membershipStarted(id, 'presale') a fresh presale sale uses.
  • Preview: dueNowInCents: 0 always; openingDayChargeInCents is the new plan’s own first-charge price via chargeableAmountInCents (the row’s own introCyclesRemaining, but NEVER its negotiatedPriceInCents — the swap always clears that field, so the preview must not show a number the actual swap will never honour — same resolution the opening-day promotion charge itself uses); openingDayAt is the row’s unmoved nextChargeDate; creditsAfter is the row’s own remainingCredits (untouched — a presale confers no access yet, so there’s nothing to sweep).
  • Member actions: scheduled resolves to ['withdraw_scheduled', 'change_plan'] in resolveMemberActions only when the plan carries a planGroupId — ['withdraw_scheduled'] alone otherwise.
  • Response shape: mode: 'presale_swap' distinguishes it from the checkout/scheduled shapes an active-sub change returns (MemberChangePlanPresaleSwapResponse — dueNowInCents, openingDayChargeInCents, openingDayAt, effectiveAt, subscription).

Direction, timing & proration (§4.2, PD-B2)

computeDueNow (plan-change-math.ts:63) — pure function, no DB/framework dependency:

periodDays = PLAN_CHANGE_INTERVAL_DAYS[oldInterval] ?? 30 // 7/30/91/365 daysRemaining = clamp(ceil((currentPeriodEnd - now) / 86_400_000), 0, periodDays) unusedCredit = floor(oldPriceInCents × daysRemaining / periodDays) dueNow = newPriceInCents - unusedCredit // may be negative

All integer cents, never floating point. classifyDirection(dueNow) (plan-change-math.ts:94): dueNow >= 100 (≥ ₪1) is an upgrade; anything else (including negative) is a downgrade/lateral. dueNow is never allowed to go below zero in a charged amount — a negative/sub-₪1 result simply defers the whole change to the boundary. No refunds, no credit balances, ever — unused time credit can only offset a new charge, never come back as money (PD-B2b).

ClassificationMember self-serveOrg-initiated
Upgrade (dueNow ≥ ₪1)Immediate, hosted checkout charges dueNowImmediate saved-card charge of dueNow, or comp, or scheduled — staff picks
Downgrade/lateral (dueNow < ₪1)Scheduled at period end (forced)Scheduled (default) or immediate comp (no money moves, no credit)

Immediate change — three billing variants (§4.3, PD-B3)

One mechanic for all three: old sub cancelled (cancellation_reason='plan_change') + new sub row created with changed_from_subscription_id lineage pointing at the old row.

VariantEntry pointMoneyNew periodCredits
Member checkoutPlansService.purchase({ replacesSubscriptionId }) → hosted page charges dueNow → webhook activation hook (webhook-processing.service.ts:415-505)hosted-page, dueNowfresh full period from activationnewPlan.classCredits
Org saved-card chargePlanChangeService.applyImmediateCharge (plan-change.service.ts:454)synchronous token charge of dueNow, pending-row-firstfresh full period from nownewPlan.classCredits
Org compPlanChangeService.applyImmediateComp (plan-change.service.ts:642)noneinherits the old sub’s currentPeriodEnd (start=now), nextChargeDate unchangednewPlan.classCredits
  • The charge variant requires providerConfig in CHARGE_VERIFIED_PROVIDERS (same allowlist as manual charge, see payments/behavior.md) and an active payment method; dueNow < 100 is rejected with a 400 telling the caller to use period_end or skip_payment instead. Charge failure → 402 PLAN_CHANGE_CHARGE_FAILED, nothing else mutates (old sub untouched, no new sub row) — same “no partial state” convention as the C1 renew fix.
  • Both charge and comp run the cancel-old + insert-new + booking sweep inside one SERIALIZABLE transaction (with retry on 40001).
  • Webhook activation hook (member checkout): gated strictly on the activated flag — the real pending → active transition on a sub carrying changed_from_subscription_id. A webhook replay (sub already active) can never re-trigger the old-sub cancel or the sweep — one activated=true moment per subscription, ever. If the old sub was already terminal by the time the webhook lands (e.g. staff cancelled it mid-checkout), the cancel step no-ops but the sweep + notification + payment.plan_changed event (carrying priorState) still run. B11 fix (FIT-254 review Wave B): the period-advance block right after activation resets remainingCredits to newPlan.classCredits unconditionally — correct on the real activation, but a REPLAY (activated stays false because the sub was already active) on a subscription carrying changed_from_subscription_id would re-run the same reset and silently erase whatever the post-activation sweep had already deducted for kept confirmed bookings (a banked-quota hole — same class of bug A3 fixed for the renewal-cron boundary swap). Guarded: skip the reset only when !activated && sub.changedFromSubscriptionId; every other replay (an ordinary, non-plan-change renewal) is untouched.
  • Consumables on the new sub always start from newPlan.classCredits fresh — old-sub unused credits are not carried over (PD-B4): their monetary value is what the proration credit already compensated; carrying both would double-count.
  • PLAN_CHANGE_INTERVAL_DAYS is a private duplicate of the same 7/30/91/365 table used elsewhere (plan-change-math.ts:20) — deliberate, so the pure math module has no NestJS import.

B1/B2 — advisory lock + re-verify + conditional swap + deferred completion (FIT-254 review Wave B, CRITICAL): the org saved-card charge variant (applyImmediateCharge) charged the member’s card BEFORE the cancel-old/insert-new swap, with the swap itself unconditional and the pending transaction marked completed immediately after the charge succeeded — a double-click, or a concurrent staff change racing the same subscription, could both pass eligibility and both charge, with only one swap actually landing coherently; and if the swap transaction failed for any reason AFTER a successful charge, the txn was already completed even though no plan change had happened. Three-layer fix, entirely inside applyImmediateCharge:

  1. Per-subscription advisory lock (pg_try_advisory_lock/pg_advisory_unlock, keyed by a 32-bit FNV-1a hash of taikan:plan-change:<subscriptionId> — same idiom as RecurringChargeService’s cron-wide lock, A5) held for the entire method. Not acquired → 409 PLAN_CHANGE_IN_PROGRESS, adapter never called.
  2. Re-read + re-verify inside the lock, before charging: reloads the subscription, re-checks status === 'active', re-runs the full §4.1 eligibility matrix, and recomputes dueNow from the reloaded row — never charges a stale amount computed before the lock was acquired.
  3. Conditional swap + deferred completion: the old-sub cancel is now WHERE id=... AND status='active' with .returning(). Zero rows (the sub changed under the lock anyway — e.g. the renewal cron doesn’t take this lock) throws internally and is caught alongside a genuine swap-transaction failure; either way the pending txn is flagged via PaymentService.flagTransactionForManualReview (pending → refund_pending, visible to reconciliation) and the caller gets 409 PLAN_CHANGE_CONFLICT — never a completed txn pointing at a plan change that didn’t happen. The txn’s updateStatus(..., 'completed') call itself moved to AFTER the swap transaction commits (order: charge → swap tx → completed → receipt/events); the charge-failure path (mark failed, throw 402) is unchanged.

B6 — cross-currency proration guard (FIT-254 review Wave B): computeDueNow blindly subtracted cents from two plans’ prices with no currency check — a cross-currency change (rare, but possible if an org’s plans were seeded/edited inconsistently) produced a meaningless number. Guarded at every call site: resolveDirectionAndTiming (preview + member self-serve), applyImmediateCharge’s own recompute, and PlansService.purchase’s replacesSubscriptionId path — each throws 409 PLAN_CHANGE_CURRENCY_MISMATCH before the math runs.

B12 — deferred promotion emails now wired (FIT-254 review Wave B, CRITICAL regression from Wave A): A8 (Wave A) made sweepEngine.apply/applyInPlace withhold waitlist-promotion emails when the caller supplies tx, returning them as pendingEmails for the caller to dispatch once its own transaction commits — but neither immediate-change variant ever called sweepEngine.sendPendingPromotionEmails, so promotion emails from plan-change sweeps were silently never sent. Fixed once, in afterImmediateChange (the single place both applyImmediateCharge and applyImmediateComp funnel through after their swap transaction has resolved) — covers both variants without duplicating the call at each site.

Scheduled change (§4.4, PD-B5)

Staff or member-downgrade path. Writes subscriptions.scheduled_plan_id + plan_change_scheduled_at (the notice timestamp) via PlanChangeService.scheduleChange (plan-change.service.ts:750); no immediate mutation to plan_id/credits. Emits payment.plan_change_scheduled and sends the “switches on ‹date›, next charge ₪Y” confirmation email (doubles as the IL consumer-law timestamped-notice record).

C8 — effective date localized in the template, not the caller (FIT-254 review Wave C): the confirmation email’s ‹date› used to be formatted by scheduleChange itself via a hardcoded toLocaleDateString('en-US', ...), regardless of the recipient’s actual locale — wrong for he/ru members in an otherwise fully localized flow. The raw currentPeriodEnd (or null) is now passed through to SweepNotificationsService.sendPlanChangeScheduled, which formats it inside templates/plan-change-scheduled.ts using the SAME resolved locale (en-US/he-IL/ru-RU) the rest of the email’s copy uses; null renders the localized noBillingDate fallback (“the next billing date”).

Applied at the boundary by the renewal cron — see the swap-first-then-bill note under Renewal cron above. Cancelling a schedule (DELETE .../change-plan/scheduled, staff or the sub owner) clears both fields and emits payment.plan_change_cancelled.

  • Freeze/resume during a pending schedule: the schedule survives and applies at the (extended) boundary — freeze only blocks creating new changes, not an existing one from firing.
  • Target plan deactivated or repriced before the boundary: the schedule still applies and the boundary charges the price committed at scheduling (scheduled_plan_price_in_cents / scheduled_plan_currency, ADR-0019), not the live catalog price; the live price is used only when the snapshot is null. The member-facing scheduled-plan-change-banner.tsx still renders the target plan’s live priceInCents, so after a repricing the banner and the charge can differ.
  • A cancellation request may coexist with a scheduled plan change because it schedules nothing and clears nothing. Staff issuance supersedes the scheduled change and clears it in the same guarded write.

B5(a) — mutual exclusion enforced at write time, not just read time: scheduleChange guards against an already-issued cancellation (cancel_at_period_end=true). issueMemberCancellation clears a previously scheduled plan change when it issues the notice. A mere memberRequestCancellation does neither.

Member self-serve (§4.5)

PlanChangeService.memberChangePlan. The member only submits newPlanId; there is no member-initiated immediate change and no checkout mode:

  • Presale row (status = 'scheduled'): same-group swap via applyPresaleSwap — see Presale swap.
  • Everything else, upgrade or downgrade: enforceMemberTargetEligibility → resolveDirectionAndTiming → enforceEligibility → scheduleChange. The change applies at the period boundary and the response is { mode: 'scheduled', effectiveAt, acceptedChargeInCents, acceptedCurrency, chargeFirst, scheduledPlanId }, where acceptedChargeInCents/acceptedCurrency is the snapshot the boundary will charge (ADR-0019) and chargeFirst says whether the new plan costs more than the current chargeable amount (then the boundary charges before swapping). A charge-first boundary that declines enters debt at the target price and grants the period on credit under the target plan’s interval and allotment (scheduledBillingRow feeds the debt entry on both collectors); the plan row itself swaps when the debt is paid, so the next charge lands one target interval after day 0, never one old interval. A checkout-based upgrade whose old plan is in debt at activation cancels that row as plan_change and clears its debt: the member just paid for the plan that replaces it.

No approval loop (confirmed product decision). Abuse controls instead: the booking sweep (below) so quota can never be banked across a change; every member change applies at the boundary; at most one pending change per sub — a second scheduleChange while scheduled_plan_id is set is refused with pending_action_conflict until the first is cancelled.

GET .../subscriptions/:subscriptionId/change-plan/preview?newPlanId=&timing=&billing= (PlanChangeService.previewChangePlan, plan-change.service.ts:115). Callable by the sub owner or staff with subscriptions/manage. Dry-runs classification + proration + a zero-write sweep simulation (sweepEngine.planForTargetPlan) and returns { direction, timing, dueNowInCents, nextChargeInCents, nextChargeDate, effectiveAt, creditsAfter, bookings: { kept, revoked } }. Both the staff dialog and the member sheet render this before enabling any confirm button — apply always recomputes at commit time (preview is advisory, matches the checkout-permutation table above). creditsAfter is the projected remaining balance after re-attributing kept confirmed future bookings to the target plan. Kept waitlisted bookings do not consume a credit until their later promotion, matching the write path.

B13 (FIT-254 review Wave B, product decision per spec §8): staff need subscriptions manage, never view — coaches are finance-blind, no exceptions; sub ownership is untouched (a coach previewing their own subscription as its member-owner still works).

B8 fix (FIT-254 review Wave B, existence oracle): ownership/permission is now resolved BEFORE the target plan is ever loaded. Previously, loading the sub and the target plan happened together, and the target-plan lookup’s plan-type check (400 Only subscription-type plans support plan change) could fire before the caller’s authorization was checked — letting a non-owner, non-staff caller learn whether an arbitrary subscription’s plan happens to be subscription-type, by probing 400-vs-403 responses. memberChangePlan had the identical issue (ownership check ran after the plan-type check); both now resolve ownership/permission first, so an unauthorized caller always gets the same 403/404 they’d get for any subscription in the org.

Cancel a scheduled change

Refused with 409 debt_clearance_required while debt_since is set: a charge-first boundary that declined entered debt at the target plan’s price, and cancelling the schedule would leave that balance owed against the old plan.

DELETE .../subscriptions/:subscriptionId/change-plan/scheduled (PlanChangeService.cancelScheduledChange, plan-change.service.ts:364) — same route, dual authorization: sub owner or staff with subscriptions/manage. Clears scheduled_plan_id/plan_change_scheduled_at, emits payment.plan_change_cancelled, sends the schedule-cancelled confirmation email. B8 fix (FIT-254 review Wave B): requireMembership now runs BEFORE the subscription lookup (previously the reverse) — a caller who isn’t even a member of the org used to get a 404 (revealing whether subscriptionId exists in some org) instead of the 403 they’d get for any other action scoped to the org.

Conflict interplay with cancellation

  • cancel_at_period_end=true (a staff-issued cancellation notice) blocks a scheduled plan change (pending_action_conflict) but allows an immediate change, which implicitly clears the flag.
  • A pending cancellation request can coexist with a scheduled plan change. Staff issuance clears that schedule atomically before setting the cancellation notice fields.

Events (§4.8, PD-B9)

payment.plan_change_scheduled, payment.plan_change_cancelled, payment.plan_changed { mode: immediate_checkout|immediate_charge|immediate_comp|scheduled, fromPlanId, toPlanId, dueNowInCents, bookingsKept, bookingsRevoked, waitlistPromotions }. A scheduled change applying at the boundary does emit SUBSCRIPTION_RENEWED (it is a renewal — membership_renewed automations firing is correct). Change flows never call CancellationNotificationsService — the consolidated sweep email is the only member communication for a change, plus the standard receipt when money moved. See sweepEnded automation exclusions below.

Booking-entitlement sweep (FIT-254 §4.6/§5, the core abuse control)

Runs whenever a subscription’s entitlements shrink or end, so booked-but-unconsumed quota can never be banked across a plan change or carried past a subscription’s death. Engine: BookingEntitlementSweepService (apps/api/src/bookings/booking-entitlement-sweep.service.ts). Deliberately depends only on booking-enforcement.util’s free functions, never BookingsService, to avoid a DI cycle (BookingsService → SubscriptionsService → sweep engine).

Algorithm

  1. Collect the old subscription’s bookings (bookings.subscription_id = oldSubId) with status IN ('confirmed', 'waitlisted') on sessions starting after GREATEST(effectiveAt, now) (FIT-254 review Wave A, A7 — see below). Untracked (subscription_id IS NULL) and staff bookings are never touched.
  2. Sort soonest session first (deterministic; secondary sort by booking id on ties).
  3. Classify each candidate against the new plan’s rules with evaluateBooking (libs/shared/src/lib/booking-evaluator.ts), the evaluator book(), schedule eligibility and waitlist promotion use: the same quota windows, the overlap rule, the credit rule and the plan’s buckets, simulated in memory. With the per-org flag booking-policy-rules on it also judges a pack’s expiry and the new plan’s horizon, so a booking moved onto a plan that ends (or a pack that expires) before its session is revoked; off (the default), such a booking is kept, as before.
  4. newSubscriptionId = null (entitlement ending outright) → everything is revoked, nothing is classified.
  5. Revocation = admin-cancel semantics: no cancellation-window check, status='cancelled' + cancelledAt, every freed confirmed seat refilled with fillFreeSeats (trigger sweep; the same eligibility as every other fill — plan rules always, quotas only with booking-policy-rules on — see scheduling-bookings › Waitlist). Promotions and skips are announced after commit; inside a caller’s tx they ride on pendingPromotions / pendingWaitlistSkips and sendPendingPromotionEmails publishes them. cancellation_reason='entitlement_sweep' on revoked rows. No credit refund to the old subscription.
  6. Re-attribution: kept bookings are re-pointed to the new subscription; a kept confirmed booking deducts one credit from the new subscription’s just-set allotment (identical to booking fresh on the new plan); a kept waitlisted booking is only re-pointed — its credit is charged at promotion time, same as any other waitlist entry.
  7. Preview (plan()/planForTargetPlan) and apply (apply()/applyInPlace()) share the exact same classification function (computeSweep/classifyForNewSub) — they can never disagree on what gets kept vs. revoked, only on whether writes happen.

A7 — stale effectiveAt guard (FIT-254 review Wave A): filtering candidates on effectiveAt alone is wrong once a boundary sweep runs meaningfully later than the boundary itself — a period-end/debt-entry sweep can run up to ~24h after the instant it’s attributed to (the cron ticks once daily). A session that started between the boundary and the actual sweep time was previously treated as “future” (it’s after effectiveAt) and got revoked/promoted-into even though it had already started or finished. Fixed: the candidate filter is now startsAt > GREATEST(effectiveAt, now) — strictly future at BOTH the attribution boundary AND the actual sweep time. effectiveAt still governs attribution (why a booking is in scope at all); this only tightens which of those attributed bookings are still actionable. The promotion engine (fillFreeSeats in booking-enforcement.util.ts) guards this for every caller too: it promotes nobody on a session starting within 15 minutes or whose registration has closed (see scheduling-bookings › Waitlist).

Two apply shapes

  • apply({ oldSubscriptionId, newSubscriptionId, effectiveAt, tx? }) — re-attributes to a different, pre-existing subscription. Used by: immediate plan change (both charge and comp variants, in the same transaction as the cancel-old/insert-new), the webhook activation hook (its own transaction, right after the activation transaction commits), and C4 (subscription ending outright, newSubscriptionId: null).
  • applyInPlace({ subscriptionId, effectiveAt, tx? }) — re-validates the same subscription’s own future bookings against its just-swapped plan. Used only by the renewal cron’s scheduled-change boundary swap (PD-B5a): the caller has already swapped plan_id and reset remaining_credits in the same transaction, so quota counts start at zero (no pre-existing “baseline” to seed from — the sub’s own future-booking ledger is the candidate set).

Both open and commit their own SERIALIZABLE transaction (with retry on 40001) when the caller doesn’t supply tx — the two C4 call sites (immediate cancel, debt entry) don’t have an enclosing transaction to join.

C4 — overlap baseline is membership-wide, day/week caps stay per-subscription (FIT-254 review Wave C): apply’s baseline (loadBaseline, feeding computeSweep) previously loaded only the NEW subscription’s own existing bookings to seed the overlap-interval set — but book() itself checks overlap across all of the membership’s confirmed/waitlisted bookings, any subscription or none (untracked). A sweep that only ever saw the target sub’s own bookings could keep a re-attributed booking that, post-swap, actually overlaps something else the member holds elsewhere on the same membership — a mirror-leak against what book() itself enforces. Fixed: one OR’d query feeds both baselines, split in memory — the overlap interval set covers the membership’s confirmed/waitlisted rows (membership-wide, any subscription or none), while day/week/month counts only accumulate rows where subscriptionId === newSubscriptionId (matches book()’s own per-subscription quota queries). Since TKN-457 the count side reads every status book() counts (QUOTA_COUNTED_STATUSES: confirmed, waitlisted, attended, no_show), so an attended or no-show booking on the target subscription fills a cap slot here too; it never enters the overlap set, because book()’s overlap check doesn’t read it. applyInPlace’s empty-baseline boundary-swap semantics are untouched — it has no pre-existing baseline concept by design.

Notifications (PD-B8)

One consolidated email per sweep — never one email per revoked booking. Two sending methods (notifications/templates/entitlement-sweep.ts, sent via SweepNotificationsService), the second with two variants:

  • sendPlanChangeSwept — “Your plan change to ‹X› is confirmed. Kept: … Cancelled because they exceed your new plan: …”. Always sends (it’s the change confirmation), kept/revoked empty or not.
  • sendSubscriptionEndedSwept — the C4 paths. C2 fix (FIT-254 review Wave C): the variant param now distinguishes a genuine subscription END (subscription_ended, default — immediate cancel/period-end cron — “your membership has ended, these bookings were cancelled”) from a non-payment cancellation (payment_issue — endSubscription('non_payment')). Non-payment is recoverable (paying the debt brings the sub straight back to active), so reusing subscription_ended’s “membership has ended” copy was simply wrong; payment_issue says “cancelled due to a payment issue” instead. C2(b): a sweep that revoked NOTHING now skips the send entirely (checked before even loading recipient context) — previously it always sent, stacking an empty-handed email on top of the cancellation/debt-warning email the same event already triggered.

C1 — templates hardened against HTML/$-pattern injection (FIT-254 review Wave C): every user/staff-controlled value interpolated into these templates (and plan-change-scheduled.ts) — session titles, plan names, member names — is now HTML-escaped (templates/template-utils.ts’s escapeHtml, extracted out of payment-receipt.ts’s own private copy), and every token substitution goes through a $-pattern-safe replaceToken helper instead of a bare .replace(token, str) — the latter treats $&/$$/etc. in the replacement as special substitution patterns even when the search argument is a plain string, silently corrupting output for a value that happens to contain a literal $ (e.g. a plan named “$99 Special”).

Per-booking cancellation emails are suppressed for sweep-revoked bookings. Current behavior: sweep promotions are announced like every other promotion — a post-commit booking.promoted event handled by BookingNotificationsListener (email + push behind the bookingUpdate opt-outs, WhatsApp, T-30 reminder; see scheduling-bookings › Waitlist). When the caller supplies tx, they are returned as pendingPromotions and published by sendPendingPromotionEmails after the caller commits. The history below (A8, B12, the opt-out gap) describes the earlier direct-email design, which sendPromotionEmail and pendingEmails implemented; both are gone. Previously: waitlist-promotion emails (existing “You’re in!” template) were sent individually, per promotion, inside BookingEntitlementSweepService.apply/applyInPlace themselves (fire-and-forget) — but only when the call opened and committed its own transaction (tx not supplied). A8 fix (FIT-254 review Wave A): when the caller supplies tx (i.e. the sweep is running inside a transaction the caller owns — e.g. the renewal cron’s boundary swap), sending here would fire the email BEFORE that outer transaction actually commits — duplicated on a Postgres serialization retry, or phantom-sent if the transaction later rolls back. apply/applyInPlace now withhold the send in that case and return the would-be promotions as pendingEmails on the result instead; the caller is responsible for calling the new public sweepEngine.sendPendingPromotionEmails(result) once its own transaction has actually committed. RecurringChargeService.applyScheduledSwap wired this in Wave A. B12 fix (FIT-254 review Wave B, CRITICAL regression): the plan-change immediate-charge/comp variants also pass tx but never called sendPendingPromotionEmails — promotion emails from those sweeps were silently never sent. Now wired once, in PlanChangeService.afterImmediateChange (the single place both variants funnel through after their swap transaction has resolved), covering both. The webhook activation hook’s sweep call does not pass tx (it deliberately runs in its own transaction, separate from the activation write — see B10 below) — pendingEmails is always empty there and promotion emails already send immediately inside apply(); nothing to wire on that path. Own-tx calls (the two C4 call sites) are unaffected — they still send immediately after their internal transaction commits, exactly as before. These sweep-triggered promotion emails bypass the notification-prefs opt-out check (classReminder/email) that ordinary booking emails respect — sendPromotionEmail (booking-entitlement-sweep.service.ts) sends unconditionally because checking prefs would require pulling PushNotificationsService (a BullMQ/Redis dependency) into every consumer of the sweep engine. Documented gap.

All new sweep/plan-change emails are localized en/he/ru from day one (locale resolution mirrors push notifications: most-recently-seen device’s locale → org’s default locale → en) — unlike the pre-existing payment emails (English-only) and cancellation emails (RTL-hardcoded), which this work does not touch.

C3 — booking-horizon guard (bookings.service.ts book())

Two independent checks inside book(), both !isStaff-only:

  1. Pending-cancellation horizon: if the member’s active sub has cancelAtPeriodEnd=true and currentPeriodEnd set, booking a session starting after currentPeriodEnd is rejected with 409 booking_beyond_subscription_end. Before this fix, a member could book indefinitely past their own chosen cancellation date.
  2. Scheduled-change target-plan quota: if the sub has a scheduledPlanId set (mutually exclusive with cancelAtPeriodEnd — one pending action per sub) and the session starts after currentPeriodEnd, the booking is allowed, but the daily/weekly cap checks are evaluated against the target plan’s maxBookingsPerDay/maxBookingsPerWeek instead of the current plan’s — honest UX, since the sweep will re-validate against the identical rules at the boundary anyway. Reduced scope, deliberate: only the day/week caps swap to the target plan; the overlap check and credit-availability check still use the current subscription/plan, because remainingCredits doesn’t reset until the boundary swap actually runs — enforcing today’s balance for a booking the sweep will re-evaluate anyway is still correct, and a bolted-on “would this exceed the target’s future allotment” counter was judged materially riskier to add than reusing the existing day/week query shape.

C3-guard — week/day-cap straddle asymmetry (FIT-254 review Wave C): the target-plan day/week count queries previously counted all of the subscription’s bookings in the UTC-day/week window, including ones BEFORE the boundary — but the boundary sweep itself (applyInPlace) starts its day/week counters at zero and only accumulates candidates that are themselves strictly post-boundary. That asymmetry produced false rejections at book-time (a pre-boundary booking sharing the same day/week window as the post-boundary session consumed a target-plan quota it was never attributed to) and a mirror-leak the other way once the sweep actually ran and didn’t see that pre-boundary booking. Fixed: the daily/weekly count queries now add startsAt > currentPeriodEnd whenever they’re judging against the TARGET plan (not the current one), mirroring the sweep’s own post-boundary-only counting exactly.

C4 — sweep on subscription end

Every path that terminates a subscription’s entitlements outright calls the same private helper (sweepOnEnd in subscriptions.service.ts):

TriggerCall siteeffectiveAt
Immediate cancel (admin)SubscriptionsService.applyImmediateCancel → sweepOnEndnow
Period-end cancellation cronSubscriptionsService.sweepDueCancellations → sweepOnEndthe period boundary that just passed (not “now” — bookings made for right after the boundary, before this cron tick ran, never belonged to the ended period)
Lifecycle end (endSubscription: non-payment, expired, gift ended, term ended)SubscriptionsService.endSubscription → sweepOnEnd (payment_issue variant for non-payment)now

Entering debt sweeps nothing: a member in grace keeps their bookings.

Freeze is explicitly exempt in v1 — pausing a subscription does not sweep its future bookings (freezes are short and staff-mediated; revoking on freeze was judged a goodwill risk not worth taking without usage data). No call site exists for freeze; this is a deliberate scope cut, not an oversight.

B10 — sweep-failure resilience (FIT-254 review Wave B): every one of the writes above (the admin cancel, the cron’s status flip, the webhook’s plan-change activation) commits BEFORE its sweep runs — a sweep failure must never undo, or appear to undo, an already-durable entitlement change. SubscriptionsService.sweepOnEnd (shared by applyImmediateCancel and sweepDueCancellations) and the webhook activation hook’s post-commit block now both catch sweep failures: log + emit payment.sweep_failed (subscriptions.service.ts path) or payment.activation_failed (webhook path) + open a high-priority general task naming the affected subscription(s) so staff can reconcile bookings manually, then return normally. The admin’s cancel request still returns success (the cancel itself committed); the webhook still returns 200 to the provider (a 5xx would just make it retry the whole webhook pointlessly). RecurringChargeService’s own sweepOnDebt/applyScheduledSwap call sites were out of scope for this wave.

sweepEnded automation exclusion (FIT-254 §4.8, PD-B9)

AutomationSchedulerService.sweepEnded (automation-scheduler.service.ts) drives membership_ended win-back automations by scanning currentPeriodEnd in a trailing window — blind to why the sub ended. Two exclusions added:

  1. cancellation_reason != 'plan_change' (or null) — a plan-changed old sub is not a churn event; without this, every plan change would misfire a win-back automation at the old sub’s original period end.
  2. NOT EXISTS another still-live subscription-type subscription on the same membership — catches the pre-existing false positive where a member independently bought a new plan; the membership hasn’t actually ended even though this particular sub’s currentPeriodEnd falls in the window. A9 refinement (FIT-254 review Wave A): “still-live” originally meant any active|past_due|paused other sub. A past_due sub whose next_charge_date is NULL is a zombie — retries stopped without ever reaching debt or recovering (e.g. a race, or a manual DB edit) — and counting it as “still holds a live sub” suppressed a genuine win-back for that membership forever, since a zombie by definition never changes state again. Refined to: active/paused always count; past_due only counts when it’s still actually retrying (next_charge_date IS NOT NULL).

Credits

The balance lives on the entitlement ledger (see Entitlement ledger); remaining_credits is its mirror.

  • Booking spend and cancel refund — debitBookingCredit / reverseBookingCredit (apps/api/src/entitlements/entitlement-ledger.ts), called from the booking primitives in booking-enforcement.util.ts. Unlimited plans (no classCredits) spend nothing. A spend is refused when the ledger balance is 0.
  • SubscriptionsService.refundCredit (late-cancel waiver) and adjustCredits (owner/admin) — both through applyManualCreditChange: the ledger balance moves by the amount, floored at zero, with a reason and an audit row.

Permissions matrix

ActionRoleSource
Create planowner/adminplans.service.ts:49
Update planowner/adminplans.service.ts:134
Delete planowner/adminplans.service.ts:181
Purchaseactive memberimplicit (requireMembership)
Enroll memberowner/adminsubscriptions.service.ts:175
Renew subsub owner OR owner/adminsubscriptions.service.ts:232
Cancel sub (admin)owner/adminsubscriptions.service.ts:302
Request own cancellationsub ownersubscriptions.service.ts (memberRequestCancellation)
Issue member cancellation noticeowner/admin, subscriptions/managesubscriptions.service.ts (issueMemberCancellation)
Freeze / resume / adjust-creditsowner/adminsubscriptions.service.ts
Acknowledge / contact / set outcome / withdraw a retention caseowner/admin, subscriptions/manageretention-case.service.ts (requireManage)
Preview plan changesub owner OR staff subscriptions/manage — B13 (Wave B): manage required, no view carve-outplan-change.service.ts:136-159
Org change plan (all variants)owner/admin, subscriptions/manageplan-change.service.ts:899
Member change own plansub ownerplan-change.service.ts:279-296
Cancel scheduled changesub owner OR owner/admin subscriptions/manageplan-change.service.ts:391-414
Manual charge / clear debtowner/admin, payments/managemanual-charge.service.ts:279-298 — see payments/behavior.md
List member payment methodsowner/admin, payments/viewpayments/behavior.md

Coach has none of the manual-charge/clear-debt/org-change-plan/preview-plan-change permissions above (finance-blind — payments and subscriptions/manage are coach-N). “Preview plan change” requires subscriptions/manage, never view (B13, FIT-254 §8).

Moving a charge date

next_charge_date and current_period_end are written together in every flow in subscriptions.service.ts. Treat that as an invariant: the sweep selects on the first, the member’s access runs to the second, and letting them drift bills people for time they don’t have or gives them time they didn’t pay for.

PATCH /organizations/:orgId/subscriptions/:id/charge-date (owner/admin, payments:manage) takes a calendar date in the org’s timezone and moves next_charge_date to it, shifting current_period_end by the same delta. Pushing a charge out two weeks therefore buys the member two more weeks of access.

Refused when: the day has passed (charge_date_in_past — backdating makes the next sweep charge immediately); the sub is cancelled/pending/paused or has no scheduled charge (charge_date_not_editable — paused is excluded because resuming already shifts the date on its own); a charge is mid-flight (charge_date_charge_in_flight, i.e. charge_started_at set on a pending row — money may have moved); or the sub is externally billed (ADR-0017). Every successful move writes a subscription.charge_date_changed audit row with the before/after dates.

Freeze/resume shifts it too. resumeSubscription extends current_period_end by the frozen duration and next_charge_date by the same amount. Until this was fixed, only the period moved: a monthly plan frozen on day 10 and resumed three weeks later kept its original (now past) charge date, so the next 02:00 sweep charged the member immediately and handleChargeSuccess reset the period to now + interval — swallowing the 21 days the freeze had just credited. The member paid early and lost the freeze. A card-less (comped) sub has a null charge date and is left null.

Opening-day billing (FIT-287)

One rule decides when a brand-new subscription takes its first charge, and it is the same rule whether staff assigned the plan or the member bought it:

firstChargeAt = max(now, organizations.opens_at, startOfDay(startsOn))

opens_at defaults to now(), so for every gym that never set an opening day this reads as “now” and nothing about the old behavior changes. A future opens_at turns the shop into a presale.

Gym already openGym opens later (presale)
Staff assigns a planCard charged now; sub activeNothing charged; sub scheduled
Member buys a planHosted page charges; sub activeHosted page tokenises (tokenOnly); sub scheduled
next_charge_datenow + intervalopening day

A scheduled subscription is a completed sale that confers nothing: no period, no credits, no quota_anchor_at, no booking access. It holds a FIT-282 purchase-cap seat and counts as a duplicate, so the same plan cannot be sold to the same member twice.

Promotion. RecurringChargeService’s sweep selects scheduled rows (next_charge_date <= now) and charges them. On success promotePresaleSubscription grants everything the sale withheld, anchoring the quota window on the CHARGE, not the sale. SUBSCRIPTION_RENEWED is deliberately not emitted — nothing renewed, and the automations engine would otherwise thank a brand-new member for renewing.

Decline on opening day. A declined first charge, or a card expired or missing on the charge date, follows the renewal rule: the presale starts that day as if paid (period, credits and quota from the charge day), the row enters debt for the agreed presale price, retries run per f(N) and on day N the lifecycle cancels it for non-payment. Day 0 also opens a presale_charge_failed retention case with an urgent owner task, so staff can call the new member. A capped presale keeps its seat during grace. See payments › Presales.

Staff override. The Assign dialog offers “charge today instead” (chargeNow: true) only while the org is presaleing AND the plan is paid AND a card is on file — there is nothing to override otherwise. It can only pull a charge earlier, never push one out, so it can’t be used to hand out unbilled memberships. Deferral is the default; the override is opt-in.

Staff-dated start. The Assign dialog also takes a start date (startsOn, a calendar date in the ORG’s timezone; absent or today = starts now). A future date defers BOTH access and the first charge to that day, using the same scheduled machinery as presale — including the Cardcom-only tokenisation requirement below. It beats chargeNow (a typed date is the more specific instruction) and can never pull a charge in front of opening day. The instant lands on subscriptions.starts_on, and the member-facing state reads it as the presale state’s atDate. resolvePlanStartAt (apps/api/src/common/plan-start-date.ts) owns validation for every path: a real calendar day, not in the past, within 365 days, else 400 plan_start_date_invalid.

Who promotes a dated sale. A card sale is promoted by the recurring-charge sweep, because taking its first charge IS the promotion. A cash or ₪0 sale has no charge to hang that on, so CashPlanCoverageService.promoteDatedStarts (daily, 02:45 UTC, before the coverage roll) activates every scheduled row whose starts_on has arrived and that the charge sweep never visits — i.e. next_charge_date IS NULL, which covers a cash sale, a free plan, a gift and an unpaid term whatever rail they sit on: period, credits and quota_anchor_at are granted from starts_on, and SUBSCRIPTION_STARTED is emitted then rather than at the till. Idempotent — the UPDATE re-asserts status = 'scheduled'.

Nothing starts before the doors open, on any rail. assignPlanBilling reads the org once (opens_at + timezone) and resolves ONE effective start for every rail: the later of the typed startsOn and opening day. A gift, an unpaid term and a free plan sold during presale land scheduled with starts_on = opens_at — a bounded gift’s coverage and an unpaid term’s grace both count from that day, not from the till — and promoteDatedStarts turns them on. A cash sale quoted before opening (buildCashQuote, enrollment only) prices and starts its coverage from opening day the same way; a renewal never waits, it extends coverage the member already holds. The card rail keeps its own deferral (next_charge_date = opens_at, the charge sweep promotes). A typed date can only push a start LATER than opening day, never earlier, and the Assign dialog’s date picker floors at opensOn during presale and says so for every non-card rail (presaleStartsNotice).

Cash dated ahead. The money is taken at the desk today; only coverage moves. cash_plan_coverages.coverage_start and every per-period price are quoted from startsOn (so intro cycles graduate over the periods the member will actually hold), startsOn is bound into the quote token, and cash_prepaid_through runs from the start date. A renewal bought early is NOT a dated sale: its coverage also begins in the future, and it keeps conferring access throughout.

Gift (₪0). A comp is an agreed price of ₪0, offered in the Assign dialog as a third payment option beside card and cash. Any sale whose effective price is ₪0 — free plan, negotiatedPriceInCents: 0, or a ₪0 cash quote — routes to the free path: no provider, no cash quote, no tax document. Before this, a ₪0 price on a PAID plan still went down the charge path (or was refused on the cash rail with “amountInCents must not be less than 1”), so the desk could not give anything away.

How long a gift lasts. A count plus a unit — “3 months”, “14 days” — travelling as giftDuration. Cleared (“no end date”) — and every gift on a one-time plan, which already ends on its own credits or expiry — is the OPEN-ENDED grant: nothing charges it, so the renewal sweep never visits, and nothing closes it. A bounded gift is written as coverage GRANTED rather than collected (grantGiftCoverage): payment_collection_method = 'cash' + cash_prepaid_through = start + giftDuration + a cash_plan_renewal task on cash_renewal_task_id (openCashRenewalTask, the same one a cash sale opens), with no payment transaction and no cash_plan_coverages row, because no money changed hands and there is nothing to receipt, refund or reconcile. The task is what the rail’s readers find a term by: the coverage sweep escalates it, and finance.cash_plan_renewals LEFT JOINs coverage and transaction so a granted term is listed with its money columns empty rather than dropped. That shape is deliberate — it is the one the rest of the system already reads: bookings refuse past the end (cash_prepayment_expired), the booking horizon caps there, CashPlanCoverageService rolls ONE ordinary period at a time so a limited plan refills monthly instead of handing over N months of credits at once, and the lapse shows up in the cash-renewal insight. negotiated_price_in_cents = 0 is what tells a gift apart from a real cash sale wherever the two need different words. A dated gift needs nothing extra: promoteDatedStarts already derives the first period from starts_on, clamped to cash_prepaid_through.

How long a gift lasts, and how long a debtor has, are the same control. Both are a count plus a unit — “3 months”, “14 days” — sent as giftDuration / unpaidGrace (planDurationSchema). A month is 30 days here, matching INTERVAL_DAYS; see libs/shared/src/lib/plan-duration.ts.

Unpaid enrollment (register now, pay by a date)

Registering a member the desk has not collected from — they are abroad, they will pay on Sunday, their card is somewhere else. The term is granted now and the money is owed by a deadline the system holds.

Not debt, and sharing none of its columns. Debt is involuntary and after the fact: a charge attempted and lost, carrying a retry ladder, dunning mail, membership.payment_status, and involuntary-churn analytics. This is the opposite — the gym CHOSE to be paid later — so it never enters the retry ladder or debt mail before its due date, never counts as a collection failure while it runs, and never reads as churn risk. It lives on subscriptions.unpaid_amount_in_cents, payment_due_at, payment_last_day, payment_granted_by/at and payment_due_task_id. Once payment_due_at passes unpaid, the lifecycle job puts the row in debt (reason payment_due, day 0 = payment_due_at, amount = unpaid_amount_in_cents, unpaid columns kept) and the gym’s grace days count from there — see payments › Unpaid payment.

Grant (grantUnpaidTerm, enroll body unpaidGrace). The subscription is active from day one — access is the point of the grace. The row records what is owed (chargeableAmountInCents, so intro and negotiated pricing apply) and when. next_charge_date stays null, so period two can never be billed before period one is paid, and the RAIL is deliberately left undecided: the member may have no card, and the answer is whatever they hand over. No provider is touched, no card is required, and no document is issued — nothing was received yet. The task insert and the subscription update commit together.

Two dates, counted in org calendar days (TKN-426). The grace runs from the org-local day access begins — the typed startsOn, else opening day during presale, else the org’s today — so a term sold ahead is not half-expired before the member can use it. unpaidTermDates(startDate, grace) (libs/shared/src/lib/plan-duration.ts, a month = 30 days) gives:

  • payment_last_day = start + N − 1 — the last day to pay, a plain YYYY-MM-DD shown to the member and the desk, and the payment_follow_up task’s due date;
  • payment_due_at = org-local midnight of start + N — the instant the term binds (stored-instant semantics unchanged).

Example: start 20 Oct, 7 days → last day 26 Oct, booking stops 27 Oct 00:00 org time. Date-string arithmetic then one zoned conversion, so a DST change inside the window moves neither date (Asia/Jerusalem, 2026: 2026-10-26T22:00:00Z). Before this, the deadline was start + grace added in milliseconds, shown as that instant’s date in the browser’s timezone, and the task was dated from a UTC slice of it; migration 0161 backfilled payment_last_day and re-dated open tasks.

Cash received later. In the Assign dialog a cash sale with a future start (a dated startsOn, or presale) offers Received now (today’s cash sale, invoice today) or Received later (enroll-cash-timing-later): the latter is exactly this unpaid term — body { planId, startsOn?, unpaidGrace }, no payment method, quote or document now — and is limited to one period. The dialog’s notice states the absolute last day — The plan starts {start}. Pay {amount} by {lastDay}. — for a future start, otherwise a dated “last day” sentence; no “now”/“from today” wording in any locale.

Enforcement is at booking. Before the due date the member books normally, and after it they keep booking through the gym’s grace days: the horizon is debtCancelsAt(payment_due_at), applied from the due instant even before the lifecycle pass stamps the debt. Past that the term is no longer a credit source (payment_due for an explicit pick), and on day N the lifecycle cancels the plan for non-payment with the debt kept. The collection task escalates meanwhile.

Settlement (POST /organizations/:orgId/subscriptions/:id/settle-unpaid, settleUnpaidTerm). Records the owed amount — the amount comes from the ROW, never the client, so a typo at the till cannot close an obligation cheaply — issues the tax document (the first legally correct moment for one), completes the task, and decides the rail: a recurring plan lands on the cash rail with coverage through the term that was granted, after which the ordinary coverage machinery takes over. Idempotent per subscription (key unpaid-settle:<sub>, under an advisory lock); no_unpaid_term stops a second settlement under another key. The optional paidOn (YYYY-MM-DD) back-dates the payment to that org-calendar day at org-local noon, so a browser in another timezone cannot move the receipt to a neighbouring day; a day after org today → 400. paidOn wins over paidAt. Audit payment.unpaid_term_settled.

The ordinary payment flow settles it too. ManualPaymentService.record against a subscription that still owes for its term routes to settleUnpaidTerm instead of rolling a fresh period (the member already holds the period; a second one for the price of the first would leave the obligation open behind it). The amount must equal what the row owes, else 409 unpaid_term_amount_mismatch with expectedAmountInCents — this is the path the member payments UI and the AI playbook use. With a client idempotencyKey the settlement is keyed unpaid-settle:<sub>:<key>, and record() looks that key up before reading the subscription: once settled the term no longer binds, so a replay would otherwise fall through to an ordinary second payment. The web Record payment dialog (record-payment-dialog.tsx) sends a key only on the cash-plan prepayment path, so this guard does not cover it; the badge’s Record payment action posts to settle-unpaid instead, which is idempotent per subscription.

What closing a term writes (closeUnpaidTerm, shared by settle, write-off and the record path). The unpaid columns (including payment_last_day) clear and the collection task completes. Only a row that is still live (UNPAID_TERM_LIVE_STATUSES) changes rail — a cancelled term that someone pays for is history with a receipt. A live recurring term moves to cash with cash_prepaid_through = the end of the term granted; a term still scheduled has no period yet, so its coverage is the first interval from starts_on (what promoteDatedStarts will hand out). A cash_plan_renewal task is opened (dated on the org-calendar day seven days before coverage ends), and when money arrived a cash_plan_coverages row ties it to the receipt like any cash sale. Paid after the granted period already ended: the coverage row is still written (it is the receipt’s refund basis), but the row keeps its rail, no renewal task opens and cash_prepaid_through is not set — putting it in the past would refuse the member at once. A row in debt is restored by the close (closeUnpaidTerm clears the debt); a row cancelled for non-payment whose granted period is over stays cancelled with the debt cleared.

Write-off (POST …/write-off-unpaid). Forgiveness, not deletion: the obligation clears and the granted term runs to its end. Nothing is refunded and no document is issued, because no money ever moved. The reason is kept on the closed collection task’s description; discount_reason still says why the price was agreed, which is a different fact. Audit payment.unpaid_term_written_off.

Desk actions. The unpaid-term badge on the member’s memberships tab shows the last day and, for staff with payments/manage, Record payment (unpaid-term-record-payment → settle-unpaid-term-dialog.tsx: method, a date picker capped at org today that sends paidOn only when earlier than today, reference, note, skip-document) and Write off (unpaid-term-write-off → write-off-unpaid-term-dialog.tsx: optional reason).

Ended rows stop being owed for. Nothing clears the unpaid columns at cancel, so every reader filters on UNPAID_TERM_LIVE_STATUSES: resolveMemberStatus (no payment_due state, no unpaidAmountInCents, from a cancelled row) and finance.unpaid_terms. MemberStatus.paymentDueAt carries the binding instant and MemberStatus.paymentLastDay the earliest last day to pay, each on its own field — atDate already means the notice date in leaving — and the roster badge shows paymentLastDay.

Refused while a term is unpaid, each because it would bury or move the obligation: a cash renewal (cash_prepayment_incompatible_state — settle it, don’t extend past it), a plan change (unpaid_term_open — the debt is priced against the plan the member was given), and a freeze (unpaid_term_open — otherwise the deadline drifts and a member freezes their way out of paying).

The member sees it too: UnpaidTermBanner on their payments page reads “₪X still to pay by” the last day, with payment_last_day rendered as a plain date (no timezone shift), and turns urgent once payment_due_at passes, because from that moment booking is blocked and saying so is the only honest render.

Cardcom only. Deferring requires tokenising without charging (CreateTokenOnly); every other adapter hard-refuses tokenOnly rather than silently charging. So the opening-day picker is hidden unless GET /payment-config reports supportsPresale, and OrganizationsService.update refuses a future opensOn for other providers. Presale checkout on an unsupported gateway raises presale_provider_unsupported.

What the tokenisation proves. Behind card-issuer-validation (per-org, default OFF). OFF: the page runs CreateTokenOnly, which stores the card on Cardcom’s own format checks — the issuer is never asked, so a mistyped expiry or CVV is accepted at signup and surfaces weeks later as an opening-day decline, when the member is no longer at the keyboard. ON: the page runs SuspendedDeal with a J5 authorization (AdvancedDefinition.JValidateType: 5) for the page amount. The issuer verifies number, expiry, CVV and available credit while the member is on the page, so a bad card fails in front of them and lands on the existing card_validation_failed decline path. No money moves either way, but a J5 hold reserves the page amount on the member’s card — the full plan price on a presale checkout, ₪1 on card registration — until the acquirer releases it (days, up to a month, per the gym’s acquirer agreement). Opening day still charges the stored token exactly as before; the hold is never captured. CardValidationPolicyService answers the flag for both the presale checkout and card registration (the card a staff-assigned presale member is charged on), and Cardcom’s GetLpResult classifier accepts the 700/701 codes Cardcom documents as a successful J2/J5 deal — only on a CreateTokenOnly/SuspendedDeal, never on a charging operation. The token of a suspended deal arrives in TranzactionInfo, not TokenInfo; the mapper reads both.

Side effects per action

TriggerSide effects
Plan price changeAffects all future renewals of existing subs immediately (renewal cron reads plan.price_in_cents live).
Plan delete (soft)Existing subs continue charging on the old (now isActive=false) plan. New purchases blocked.
enrollMember (paid plan)Charges the saved card, THEN creates an active sub carrying next_charge_date + payment_method_id. Declined card ⇒ 402, nothing created.
enrollMember (charge cleared, sub creation then failed)409 ENROLL_CONFLICT. The charge row is settled completed (money moved — leaving it pending was unresolvable, since a sub-less row is out of reach of every sweep and void path) before a manual-review refund task is opened on it, so the task carries the provider deal id. No subscription exists.
enrollMember (₪0 — free plan or an agreed ₪0 “gift”)Sub active, provider untouched, no cash quote and no document even when the client named the cash rail. This is how a comp membership is expressed now that assignment bills. With giftDuration on a recurring plan the gift is bounded: coverage granted through start + duration, refilled one billing period at a time; when it runs out the lifecycle ends it (gift_ended, no debt). Without it, open-ended.
enrollMember (unpaidGrace)Term granted (active now, or scheduled for a future start); unpaid_amount_in_cents, payment_last_day (start + grace − 1, org calendar) and payment_due_at (org midnight after it) stamped, payment_follow_up task due on the last day, next_charge_date null, no provider, no card required, no document. Bookable through the deadline plus the gym’s grace days; past the deadline the lifecycle puts it in debt and, on day N, cancels it for non-payment. Settle via settle-unpaid, the ordinary record flow, or forgive via write-off-unpaid. Grant never touches any debt_* column.
enrollMember (gym not open yet)Card: sub scheduled, nothing charged, next_charge_date = opening day; chargeNow: true overrides to charge today. Gift, unpaid, free and cash: sub scheduled with starts_on = opening day, coverage/grace counted from then, promoteDatedStarts turns it on.
enrollMember (startsOn in the future)Nothing conferred: sub scheduled, starts_on set, no period/credits/quota. Card ⇒ next_charge_date = that day and the charge sweep promotes it. Cash ⇒ money taken today, coverage quoted from that day, CashPlanCoverageService.promoteDatedStarts activates it. Past or >365 days ⇒ 400 plan_start_date_invalid.
freezeSubscriptionBookings refuse credits until resumed. Charge cron skips paused subs (selector requires status IN ('active','past_due','debt','scheduled')); the lifecycle never selects a paused row, so the grace clock does not run while frozen.
resumeSubscriptionShifts BOTH current_period_end and next_charge_date forward by the frozen duration. Card-less subs keep a null charge date.
updateChargeDateMoves next_charge_date and current_period_end by the same delta. Audit row subscription.charge_date_changed. No charge is taken.
Member gives cancellation noticeMember email (cancellationScheduled); urgent SLA-timed retention case; no per-event staff email (rolls into the next daily digest). See Retention case.
Staff immediate cancel with refundSub cancelled, provider standing order stopped, refund flow runs (capability-aware, resolved against the transaction’s own provider), member email (membershipEnded). Refund task created if the capability is manual.
Immediate plan change (any billing variant)Old sub cancelled (plan_change), new sub row inserted with lineage, booking sweep runs in the same transaction, consolidated sweep email sent, payment.plan_changed emitted. No SUBSCRIPTION_RENEWED.
Scheduled plan change fires at the renewal-cron boundaryplan_id swapped in place, credits reset, sweep runs (applyInPlace), then the normal renewal charge fires on the new plan — SUBSCRIPTION_RENEWED is emitted (it’s a real renewal).
Manual charge / clear debt (staff)Pending-row-first transaction charge; receipt email on success; no SUBSCRIPTION_RENEWED; see payments/behavior.md.
memberWithdrawScheduled (presale withdraw)Row → cancelled (presale_withdrawn), pending transactions cancelled, seat released, stored card untouched. payment.subscription_cancelled { source: 'member_presale_withdraw' } + audit row. No form, no refund — nothing was charged. Member release email, owner email and a low-priority, no-deadline FYI task (see What opens a case).
memberCancelPending with intent: 'regret'Row → cancelled (abandoned_checkout, filer = the member), hidden from the member’s own list only, checkoutCancel stamped on the abandoned transaction’s metadata. payment.checkout_abandoned { reason: 'dismissed_by_member', intent, source } + audit row. Still revivable by a late payment.
resumeCheckoutA fresh hosted page for the same pending row; a new/updated pending transaction via the usual upsert. payment.checkout_resumed { source: 'resume_endpoint' }. Nothing about the subscription changes.

B9 — path/query UUID validation (FIT-254 review Wave B): every :orgId/:subscriptionId/:membershipId route param on PlanChangeController and ManualChargeController (including clear-debt) now carries ParseUUIDPipe, and CardRegistrationController’s member payment-methods GET got it on :membershipId specifically. previewChangePlan’s newPlanId query param (which can’t take a Nest pipe the same way) is validated with class-validator’s isUUID instead. A garbage id now 400s at the parameter-binding stage instead of reaching a Postgres invalid input syntax for type uuid 500.

Edge cases

CaseBehaviour
Member purchases free plan they already have active409 ConflictException (plans.service.ts:265).
Member retries an abandoned checkoutPending sub reused; fresh payment page issued; no duplicate subscriptions row.
Webhook arrives for already-active subActivate transaction short-circuits; period not re-advanced if status was already active and the transition pending → active isn’t taken.
Owner cancels a paused subAllowed; transitions paused → cancelled.
Owner cancels a sub already cancelled400 Subscription is already cancelled.
Member requests twiceSecond pending request returns 409 CANCELLATION_REQUEST_ALREADY_PENDING; no timestamp reset or duplicate task. Issuance followed by a second issuance returns CANCELLATION_ALREADY_SCHEDULED.
Adjust credits to negativeClamped to 0 (subscriptions.service.ts:815).
Adjust credits on unlimited plan400 This plan has unlimited credits.
Sub stuck in pending (abandoned checkout)The member decides: resume-checkout re-issues the payment page, cancel-pending rolls it back. Failing both, PaymentMonitoringService.sweepAbandonedCheckouts releases it 24h after creation, and staff can dismiss it at any time (/subscriptions/:id/void-pending).
Sub stuck in scheduled with cancelAtPeriodEnd=trueFreed by the cancellation cron’s repair pass. New member requests on scheduled rows are rejected with 409 use_withdraw.
Member withdraws a presale, then wants back inBuying again just works: purchase only treats active|pending|paused|past_due|debt as blocking, and the seat was released the moment the row went cancelled.
Member rolls back a checkout they had already paid forRefused: voidPendingSubscription finds the settled transaction and 409s cancel_pending_unsafe.
Renewal cron hits a sub with no payment_method_idSelector excludes it (AND payment_method_id IS NOT NULL — recurring-charge.service.ts:71). The sub will quietly never renew.

Entitlement ledger (ADR-0024 D6–D8, TKN-445)

  • Dual-write, always on. Every writer of remaining_credits writes its entitlement_ledger row in the same transaction. Relative changes (booking debit, cancel reversal, manual adjustment) insert the ledger row first with ON CONFLICT (idempotency_key) DO NOTHING and move the counter only if it applied. Absolute writes (purchase grant, period refill, plan swap, debt entry, cash cancel zeroing) call recordCreditRebase after the write, which records counter − sum(delta) so the sum lands on the counter. Writes that set the counter to null (presale checkout, schedule repair) close the ledger with a reset to 0 (reason_code = 'untracked'), so no stale balance lingers while the subscription is untracked.
  • One refund per booking. A cancel reverses at most once (reversal:<bookingId>), and only a booking that was debited — or one older than the subscription’s first ledger row (spent by pre-ledger code). A second cancel, a staff cancel racing a member cancel, or a session cancel racing a member cancel refund once.
  • Manual changes need a reason. POST …/subscriptions/:id/adjust-credits takes { amount, reasonCode, note?, requestId? } and POST …/members/:membershipId/refund-credit takes { reasonCode, note?, requestId? } (zod adjustCreditsSchema / refundCreditSchema). requestId is a client uuid per click: the ledger row is keyed adjust:<subId>:<requestId>, so a double-click or a retry with the same id applies once and writes one audit row (the web popover and the session-sheet refund button send one; the bookings.refund_credit agent tool uses its tool-use id). Without it every call is its own change. Manual balance changes stay with subscriptions:manage (owner/admin), except the late-cancel waiver (refund-credit), which coaches can do with a reason (bookings:manage: owner, admin, coach; a Sales grant on a member membership does not count). Every one is audited: both floor at zero, write a ledger adjustment (with note and actor) and an entitlement.adjusted audit row (category billing, actor, before/after balance, reason, note, requested amount, applied delta). The agent tool bookings.refund_credit requires reasonCode and has the same gate as the endpoint.
  • The ledger is the sole credit authority (2026-10-14, no flag). The booking credit check, waitlist entry and promotion, schedule eligibility, manual adjustments, the plan-change sweep (its simulation and its charges), cash credit notes and cancellations (a pack’s unused share), the roster’s can-book / spent filters and member states, the low-credits insight, the platform admin subscription list, and every subscription the API returns (/subscriptions/my, /members/:id/subscriptions, the plan’s subscriber list, and every subscription mutation response) all read the ledger sum (EntitlementBalanceService.withBalances for rows in hand, creditBalance inside a transaction, creditBalanceSql() for SQL filters and lists). remaining_credits is a mirror, moved in the same transaction; dropping it is a later step. A debit locks the subscription row FOR NO KEY UPDATE, answers a key already on the ledger as a duplicate, decides on the ledger, then moves the mirror without a > 0 guard: the lock already excludes a concurrent spend, and a guard could only let a drifted mirror overrule the ledger. The entitlement-buckets flag that gated this was removed; the prod backfill ran first (20 opening rows, ledger sum = counter for every subscription).
  • Unopened ledger. A tracked subscription with no grant/reset row should not exist (every writer grants at purchase; the backfill opened the rest; TestingService and scripts/seed-entitlement-mix.ts open theirs through openUnopenedLedgers). If one is read, the reader falls back to its counter and reports it: a structured warning on every read, and a Sentry message tagged check=entitlement_ledger_unopened with the subscription id and the read path (no PII), once per subscription per API process.
  • Drift check. EntitlementDriftService.handleScan (BullMQ scheduled job, daily 04:10 UTC, only where CRONS_ENABLED=true) compares the mirror with the ledger sum for every tracked subscription and reports, each as its own Sentry message with counts and a bounded sample of subscription ids (no PII): mismatches (check=entitlement_ledger_drift, warning), unopened ledgers (check=entitlement_ledger_unopened, warning) and counters below zero (check=entitlement_ledger_negative_counter, error). A completed scan pings the HEARTBEAT_URL_ENTITLEMENT_DRIFT heartbeat, findings or not.
  • Deploy. Order: migration 0172_entitlement_ledger (runs as the API’s Railway preDeployCommand) → API → web. Between the API and web deploys, and in any tab left open from before, “Refund credit” and “Adjust credits” answer 400 because the old client sends no reasonCode; reloading the page fixes it. scripts/backfill-entitlement-ledger.ts opens the ledger of subscriptions that predate it; it ran in production on 2026-10-14 with the owner’s approval. Removing the flag needs no migration and no special order.

Ledger operations

Every ledger alert carries a check tag starting entitlement_ledger; the production Sentry rule “Entitlement ledger: drift / unopened / negative counter” pages on new, regressed or reappeared issues with that tag (email + a Linear issue). What each one means and what to do:

  • entitlement_ledger_unopened — a tracked subscription (non-null counter) has no grant/reset row, so its reads fell back to the counter. Live reports name the subscription and the read path; the daily scan reports a count. Cause: a writer that set remaining_credits without recordCreditRebase, or rows inserted by hand / by a seed. Fix the writer first, then open the affected ledgers with scripts/backfill-entitlement-ledger.ts (dry run, then --apply; it writes reset:<subId>:opening at the current counter).
  • entitlement_ledger_drift — the counter and the ledger sum disagree. The ledger is right by definition (it decides every booking); the mirror is what drifted. Read the sample subscription’s ledger history (entitlement_ledger by subscription_id, oldest first) against its updated_at and audit trail to find the writer that moved the counter alone (a raw SQL fix is the usual one). The mirror re-syncs on the next absolute write (renewal, plan change). Until then only readers outside the API (an ad-hoc SQL query, a BI export) see the wrong number; an API path that shows the counter instead of the ledger is a bug.
  • entitlement_ledger_negative_counter (error) — a counter below zero. Only a mirror that already disagreed with the ledger can get there (the debit moves the mirror without a guard), so it always comes with drift. Same investigation as drift; nothing member-facing reads the counter, so booking and credit notes are unaffected.
  • A missing HEARTBEAT_URL_ENTITLEMENT_DRIFT ping — the scan did not complete (no worker with CRONS_ENABLED=true, or it threw — the error is in Sentry). See scheduled-jobs.md.

Entry quotas (TKN-446)

ADR-0024 D5/D8 (amended 2026-10-16: quotas are limits, not pools). Behind the per-org flag booking-policy-rules, default off. What a quota does at booking: scheduling › Entry quotas at booking.

  • Shape. plan_entitlement_buckets rows: name, position (display order only), allowance (null = unlimited, 0 = the matching classes are not in the plan), period (day/week/month = the plan caps’ own quotaWindowFor windows, window = the billing cycle, term = the anchor to current_period_end for packs) and scope (the D9 condition model; space values are halls). Every quota is a limit a matching booking must fit; credits stay the plan’s. A plan with no rows behaves exactly as before. No migration writes a row (no backfill).
  • The plan’s limit columns are its all-classes quotas. maxBookingsPerDay, maxBookingsPerWeek and maxBookingsPerMonth are exactly “All classes, N per day/week/month”. With the flag on, the plan sheet shows one list: those columns as rows named “All classes” (plans.buckets.implicitName, scope all), then the plan’s quotas. On save, a row with scope all and period day/week/month (and an allowance other than 0, which no column holds) is written to its plan column in the plan PATCH or create body; every other row goes through the quota PUT. Two all-classes rows for one of those periods are refused in the sheet (plans.buckets.errors.duplicateAllClasses). The API is unchanged for the columns. Flag off, the sheet keeps its “Booking rules” section with the three fields.
  • Editing. GET …/plans/:planId/entitlement-buckets (plans:view); PUT the whole list (bookingPolicy:manage, owner/admin): array order is position, a quota missing from the list is soft-deleted, one without an id is created, all in one transaction. Refused 403 { code: 'feature_disabled' } while the flag is off. One plan.buckets_updated audit row per save with the full before/after list. The plan detail page’s card shows the same one list read-only.
  • Edits apply at once (D8, amended 2026-10-16). A quota added, changed or removed applies to every member on the plan from their next booking, like the plan’s day/week/month limits: every read judges a subscription against its plan’s live plan_entitlement_buckets rows. Bookings already made stay. There is no per-cycle copy, and nothing on the purchase or renewal path writes quota state, so plans and subscriptions without quotas are untouched.
  • Ledger. Quotas never pay: debits and reversals are the plan’s, and entitlement_ledger.bucket_id stays null (no foreign key). A quota’s usage is counted from bookings, never from ledger rows.
  • Sweep. Kept bookings are judged against every matching quota of the new plan in the same in-memory simulation as the caps; nothing is written for them beyond the caps’ writes.
  • Balances. While the flag is on, /subscriptions/my and the staff /members/:membershipId/subscriptions carry bucketBalances[] on a subscription whose plan has quotas: one line per quota (used = its matching bookings in the window containing now, remaining, the window), from one grouped read (EntitlementBalanceService.bucketBalancesFor, at most three reads per response). The member profile and the staff memberships tab show these lines under the plan’s credits line, never instead of it. A plan without quotas, or the flag off, omits the field.

Managed members

Spec: managed-members/behavior.md §4.2.

  • A child’s subscription hangs off the child’s membership; the adult who pays is subscriptions.payerUserId. Buying or changing a child’s plan needs canManageSubscription / canPay and the managed-members flag (apps/api/src/plans/plans.service.ts:1082, apps/api/src/subscriptions/plan-change.service.ts:183, subscriptions.service.ts:5043).
  • Inserting a subscription for a guardian_only adult’s own membership promotes them to trains (apps/api/src/memberships/participation.ts). Paying for a child never does.