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').
| Route | Method | Guard | Notes |
|---|---|---|---|
/ | POST | owner/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). |
/ | GET | active member | Lists plans; default filter isActive=true. Cache-Control: private, max-age=120, stale-while-revalidate=600. |
/:id | GET | active member | |
/:id | PATCH | owner/admin + @RequiresFeature | Partial update; updates plan.currency if passed. |
/:id | DELETE | owner/admin + @RequiresFeature | Soft-delete via isActive=false. |
/:id/purchase | POST | active member | Drives the member checkout. Rejects plan.type='course' (must go through /public/courses/:id/checkout). |
/reorder | PATCH | owner/admin + @RequiresFeature | Renumbers sortOrder by position. Partial-list semantics: ids not passed keep their order. Never touches planGroupId. |
/:id/subscribers | GET | subscriptions: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)
- 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 409trial_already_usedwhen the person being enrolled (the child, for a guardian) already held any trial plan of the org, or has an unpaidpendingcheckout of another one —assertTrialNotUsed, before the form gate; see Trials › One per person. - Look up any non-deleted subscription for
(membershipId, planId)with status in(active, scheduled, pending, paused, past_due, debt). The set must cover every statuscreateSubscription’s exclusivity guard refuses; a holder missing from it falls through to that guard’s genericenroll_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→ 409plan_already_held, body carriesstatusandsubscriptionId. Not a failure: the join flow (join-continue.tsx) shows the you-are-in screen and files nocheckout-failedreport. - recurring plan,
paused→ 409Resume it instead of purchasing again. past_due | debt→ 409You already have a subscription with an outstanding balance.pending→ reuse the row (generates a fresh payment page for it). When the caller passesresumeSubscriptionId(onlyresumeCheckoutdoes), 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 409scheckout_not_resumableinstead of minting a new one.- none → create a new
pending(oractivefor free plans).
- recurring plan,
- Emit
payment.checkout_startedobservability event. - Free plan: return immediately with
{ subscription }(subscription.status='active'). - Paid plan: build
ClientInfofrom member profile (taxId, address, etc. for invoicing —plans.service.ts:321), callPaymentService.createHostedPayment. - 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
resumeCheckoutis trying to finish; deleting it because the provider blipped would destroy what the member asked to resume. - 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 < ₪1now rejects with 400PLAN_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-existingMath.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 othercomputeDueNowcall 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
pendingpayment_transactionsrows are now also markedcancelled. Its hosted payment page is still live at the provider — if the member somehow completes it anyway, the webhook finds nopendingtransaction 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).
| Route | Method | Role | Effect |
|---|---|---|---|
/subscriptions/my | GET | member | Lists own subs (excluding plan.type='course'). |
/members/:id/subscriptions | GET | owner/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/enroll | POST | owner/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-date | PATCH | owner/admin + payments:manage | updateChargeDate — moves next_charge_date, shifting current_period_end by the same delta. |
/subscriptions/:id/renew | POST | owner/admin or sub owner | Charges via stored payment method synchronously; updates period. |
/subscriptions/my/:id/cancellation-request | POST | sub owner | memberRequestCancellation — 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/resume | POST | sub owner | Withdraws 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/cancel | POST | owner/admin | cancelSubscription — immediate; tries paymentService.cancelRecurring on the provider if providerSubscriptionId set. |
/subscriptions/:id/void-pending | POST | owner/admin | staffVoidPendingSubscription — 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') | POST | owner/admin | issueMemberCancellation — 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-preview | GET | owner/admin | previewCancellation — 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/acknowledge | POST | owner/admin, subscriptions:manage | Acknowledges an open retention case. |
/subscriptions/:id/retention/contact | POST | owner/admin, subscriptions:manage | Body { channel, note? }. Logs a contact attempt on an open case. |
/subscriptions/:id/retention/outcome | POST | owner/admin, subscriptions:manage | Body { 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-notice | POST | owner/admin, subscriptions:manage | Body { 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-pending | POST | sub owner | memberCancelPending — 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-checkout | POST | sub owner | PlansService.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-return | POST | sub owner | Telemetry 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/withdraw | POST | sub owner | memberWithdrawScheduled — drops a scheduled (presale) membership free of charge. See below. |
/subscriptions/:id/freeze | POST | owner/admin | active → paused; stamps pausedAt. |
/subscriptions/:id/resume | POST | owner/admin | paused → active; extends currentPeriodEnd by (now - pausedAt). |
/subscriptions/:id/adjust-credits | POST | owner/admin, subscriptions:manage | Body { 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.chargewith no token at all (worked only by accident on providers that don’t require one; failed outright on Cardcom/iCredit/Morning), and unconditionally advancedcurrentPeriodEnd+ 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) — 409NO_ACTIVE_PAYMENT_METHODif 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 status | Result |
|---|---|
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 |
active | 409 SUBSCRIPTION_ALREADY_ACTIVE — already current; renewing would double-charge the period the member is already paying for |
debt | 409 DEBT_CLEARANCE_REQUIRED — pay the debt (clear-debt / member pay-debt) instead, which restores the period it covered |
paused | 409 RESUME_FIRST — resume before renewing (mirrors plan-change’s resume_first) |
pending | 400 — 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.nullclears 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 sameSeatHoldOptionsinto the sameseatStatusCondition. A page that says “3 left” while the guard says “sold out” is worse than either answer alone. scheduledand ever-activated rows do not time out. Only the never-activatedpendingarm 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.
A sold-out offer on a join link (unflagged)
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.)
Releasing a never-paid join-link membership
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:
runSweepJoinLinkPendingCheckoutsfor 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.- 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).
runSweepUnpaidJoinMembershipsfor 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:
PaymentMonitoringService.runSweepAbandonedJoinShells— hourly, every org. Ajoin_linkshell still atpending_invitationafterJOIN_SHELL_RELEASE_HOURS(24h) with no claimed login, no subscription and no settled transaction. It still emitsMEMBERSHIP_RELEASED_UNPAID { reason: 'never_completed' }so the CRM opens a lead in “Didn’t finish signing up” — the event carries acontactsnapshot 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.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:
| Choice | Endpoint | What it does |
|---|---|---|
| Finish paying | POST /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 purchase | POST /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”:
| Situation | Response |
|---|---|
Status is active/paused/past_due/debt | 409 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 exists | 409 withdraw_requires_cancellation — money moved, so ending this has refund math attached. |
A pending transaction has charge_started_at | 409 withdraw_charge_in_flight — outcome unknown, money may already have moved. Same paranoia as voidPendingSubscription. |
Status is pending or cancelled | 400 — 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.
| Field | Meaning |
|---|---|
kind | The plan’s type: subscription, class_pack, drop_in, course. |
validUntil | Packs 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. |
consumableStatus | Packs 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). |
consumableEndedAt | Never 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 newestcancelledplan whosememberActionsincluderenew(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:availablepacks and drop-ins (newest first), thenused_up/expiredones whoseconsumableEndedAtis within 30 days, most recently ended first. An ended pack with noconsumableEndedAt(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
nextChargeAtand only appears while one is scheduled; the price shows/ intervalonly when the plan has one. Change plan and the “Unlimited” label readkind. 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_againwhile the plan is still sold (isActive, shown in shop), else/shop; the tap capturesmember_buy_again_clicked { org_id, plan_id, kind, consumable_status, on_sale }and the Shop landing tagsmember_shop_deeplinkwithentry: 'buy_again' | 'link'fromsrc;&for=<childUserId>when it is a child’s card (the Shop seeds its recipient from?for=). Rows keepcomplete_checkout,cancel_pending,resume_cancel,withdraw_scheduled; never renew. One mutedprofile.holdings.packsRunOutline 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:
| Outcome | Sub update | Membership update | Notice |
|---|---|---|---|
| Success | status=active, period advanced, attempts=0, credits refilled | payment_status='current' | none (receipt only via initial charge path) |
| Success on a debt row (retry) | debt cleared (debtClearedPatch), period unchanged, nextChargeDate = current_period_end | recomputed (current unless another plan owes) | — |
| First decline | status=debt, debt_reason='charge_failed', debt_amount_in_cents = agreed price, debt_since=now, period = the missed period (credits refilled), nextChargeDate = first f(N) retry | payment_status='debt' | debt-opened (via SUBSCRIPTION_DEBT_OPENED) |
| Declined retry | attempts++, 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.
| Row | Transition |
|---|---|
debt, cancellation instant (debtCancelsAt(debt_since)) passed, no retry still due | cancelled for non-payment (endSubscription('non_payment')), then the cancellation notice |
debt, today is the pay-by day, N ≥ 2 | last-day notice |
active, payment_due_at passed with an unpaid amount | in debt, payment_due, day 0 = payment_due_at, period kept |
active class pack / drop-in past current_period_end | ended, reason expired |
active cash subscription, cash_prepaid_through passed, coverage rows exist and the agreed price > 0 | in 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 0 | ended, reason gift_ended; open cash renewal / payment-due tasks completed |
active card subscription, period over, no next_charge_date, agreed price 0 | period rolled to the current one (credits refilled, new quota cycle) |
| same, agreed price > 0, no card on file | ended, 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, sweepOnEndOnce 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):
| Row | displayStatus |
|---|---|
cancellation_reason='presale_withdrawn' — a presale the member dropped before it started | withdrawn |
cancellation_requested_at set — staff immediate cancel, or a member notice this cron has since matured | cancelled |
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 period | cancelled |
cancellation_reason='plan_change' — superseded by a plan change | expired |
cancellation_reason expired / gift_ended / term_ended — a lifecycle calendar end | expired |
| Everything else: provider-side cancellation, account erasure | expired |
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
| Trigger | Kind (retentionCaseKind) | Behavior |
|---|---|---|
Member mobile request (memberRequestCancellation) | request | Urgent 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) | notice | Promotes 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_withdrawal | Immediate 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_failed | Urgent 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
- Acknowledge —
POST …/subscriptions/:id/retention/acknowledge(owner/admin,subscriptions:manage). SetsretentionAcknowledgedAtif unset; idempotent. Emitscancellation.case_acknowledged { hours_since_notice }. - Contact —
POST …/retention/contact { channel, note? }(channel:phone | whatsapp | email | in_person | other— distinct fromcancellationNoticeChannel, since a retention call isn’t bound to the §5-mandated legal-notice channel list). SetsretentionContactedAton the first call and always incrementsretentionContactAttempts. Each attempt also persists its channel, note, actor, timestamp and sequence number in the append-only audit trail. Emitscancellation.case_contacted { attempt, contact_channel, hours_to_first_contact }. - Outcome —
POST …/retention/outcome { outcome, note? }. Restricted tosaved | saved_change_plan | saved_paused | saved_discount | saved_quota;lost_*andreboughtare system-set only. An open request (retentionCaseKind='request'andretentionOutcome=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. Selectingsaved_discountin 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. - Stay / withdraw — the member taps I want to stay, the
resume_cancelaction, throughPOST …/subscriptions/my/:id/resume. This works immediately after a request and after staff issuance, while status isactive | 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, stampsretentionOutcome='saved'if unset, and revokes unsigned paperwork throughFormsService.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 closes | What happens |
|---|---|
Staff sets a saved* outcome, or staff withdraws on the member’s word | See “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 notice | Request/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 org | Closes 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 retries | retentionOutcome='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 andretentionAcknowledgedAtis still null. The deadline isanchor + organizations.retentionSlaHours(default 48h), where anchor is the LATER ofcancellationNoticeAtand the case’s own taskcreatedAt— not the task’s date-onlydueDate(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—cancellationEffectiveAtis between now and 7 days out (inclusive) andretentionContactedAtis 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 tocancelled, 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 date | Settlement |
|---|---|
currentPeriodEnd < cancellationEffectiveAt | One final managed-payment charge for [currentPeriodEnd, cancellationEffectiveAt), never a full renewal followed by a refund. |
| Equal | No charge or refund. |
currentPeriodEnd > cancellationEffectiveAt | Proportional 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:
resolveExecution | What settlement does |
|---|---|
automatic | Refunds refundableInCents against the paying charge through PaymentService.refund. |
task | The 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 state | Immediate change | Scheduled (period-end) change | Error 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/a | presale_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:
enforcePlanChangeEligibilityspecial-casesstatus === 'scheduled'before any of the live-sub refusals — its only question isnewPlan.planGroupId === sub.plan.planGroupId(both non-null). A mismatch (including a group-less target) refuses with 409PRESALE_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 viawithAdvisoryLock(pinned to one dedicated pool connection — seeadvisory-lock.ts), reload + re-verify status and group match under the lock, refuse if a charge is in flight (409PLAN_CHANGE_CHARGE_IN_FLIGHT— mirrorsfindWithdrawBlocker’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 asPlansService.purchase’s hidden-plan gate — staff keep desk access). Inside ONEdb.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 guardedUPDATE 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. AuditsSUBSCRIPTION_PLAN_CHANGE_APPLIED(before/after includenegotiatedPriceInCents) withmetadata.mode: 'presale_swap'; emitsplan_change.presale_swap { fromPlanId, toPlanId, daysToOpening }. Re-sends the presale confirmation email with the new plan via the samemembershipWelcome.membershipStarted(id, 'presale')a fresh presale sale uses. - Preview:
dueNowInCents: 0always;openingDayChargeInCentsis the new plan’s own first-charge price viachargeableAmountInCents(the row’s ownintroCyclesRemaining, but NEVER itsnegotiatedPriceInCents— 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);openingDayAtis the row’s unmovednextChargeDate;creditsAfteris the row’s ownremainingCredits(untouched — a presale confers no access yet, so there’s nothing to sweep). - Member actions:
scheduledresolves to['withdraw_scheduled', 'change_plan']inresolveMemberActionsonly when the plan carries aplanGroupId—['withdraw_scheduled']alone otherwise. - Response shape:
mode: 'presale_swap'distinguishes it from thecheckout/scheduledshapes 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 negativeAll 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).
| Classification | Member self-serve | Org-initiated |
|---|---|---|
Upgrade (dueNow ≥ ₪1) | Immediate, hosted checkout charges dueNow | Immediate 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.
| Variant | Entry point | Money | New period | Credits |
|---|---|---|---|---|
| Member checkout | PlansService.purchase({ replacesSubscriptionId }) → hosted page charges dueNow → webhook activation hook (webhook-processing.service.ts:415-505) | hosted-page, dueNow | fresh full period from activation | newPlan.classCredits |
| Org saved-card charge | PlanChangeService.applyImmediateCharge (plan-change.service.ts:454) | synchronous token charge of dueNow, pending-row-first | fresh full period from now | newPlan.classCredits |
| Org comp | PlanChangeService.applyImmediateComp (plan-change.service.ts:642) | none | inherits the old sub’s currentPeriodEnd (start=now), nextChargeDate unchanged | newPlan.classCredits |
- The charge variant requires
providerConfiginCHARGE_VERIFIED_PROVIDERS(same allowlist as manual charge, seepayments/behavior.md) and an active payment method;dueNow < 100is rejected with a 400 telling the caller to useperiod_endorskip_paymentinstead. Charge failure → 402PLAN_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
SERIALIZABLEtransaction (with retry on40001). - Webhook activation hook (member checkout): gated strictly on the
activatedflag — the realpending → activetransition on a sub carryingchanged_from_subscription_id. A webhook replay (sub alreadyactive) can never re-trigger the old-sub cancel or the sweep — oneactivated=truemoment 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_changedevent (carryingpriorState) still run. B11 fix (FIT-254 review Wave B): the period-advance block right after activation resetsremainingCreditstonewPlan.classCreditsunconditionally — correct on the real activation, but a REPLAY (activatedstays false because the sub was alreadyactive) on a subscription carryingchanged_from_subscription_idwould 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.classCreditsfresh — 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_DAYSis 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:
- Per-subscription advisory lock (
pg_try_advisory_lock/pg_advisory_unlock, keyed by a 32-bit FNV-1a hash oftaikan:plan-change:<subscriptionId>— same idiom asRecurringChargeService’s cron-wide lock, A5) held for the entire method. Not acquired → 409PLAN_CHANGE_IN_PROGRESS, adapter never called. - 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 recomputesdueNowfrom the reloaded row — never charges a stale amount computed before the lock was acquired. - 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 viaPaymentService.flagTransactionForManualReview(pending →refund_pending, visible to reconciliation) and the caller gets 409PLAN_CHANGE_CONFLICT— never acompletedtxn pointing at a plan change that didn’t happen. The txn’supdateStatus(..., 'completed')call itself moved to AFTER the swap transaction commits (order: charge → swap tx →completed→ receipt/events); the charge-failure path (markfailed, 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-facingscheduled-plan-change-banner.tsxstill renders the target plan’s livepriceInCents, 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 viaapplyPresaleSwap— 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 }, whereacceptedChargeInCents/acceptedCurrencyis the snapshot the boundary will charge (ADR-0019) andchargeFirstsays 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 (scheduledBillingRowfeeds 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 indebtat activation cancels that row asplan_changeand 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.
Preview (§4.7 — mandatory consent surface)
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
- Collect the old subscription’s bookings (
bookings.subscription_id = oldSubId) withstatus IN ('confirmed', 'waitlisted')on sessions starting afterGREATEST(effectiveAt, now)(FIT-254 review Wave A, A7 — see below). Untracked (subscription_id IS NULL) and staff bookings are never touched. - Sort soonest session first (deterministic; secondary sort by booking id on ties).
- Classify each candidate against the new plan’s rules with
evaluateBooking(libs/shared/src/lib/booking-evaluator.ts), the evaluatorbook(), 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 flagbooking-policy-ruleson 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. newSubscriptionId = null(entitlement ending outright) → everything is revoked, nothing is classified.- Revocation = admin-cancel semantics: no cancellation-window check,
status='cancelled'+cancelledAt, every freed confirmed seat refilled withfillFreeSeats(triggersweep; the same eligibility as every other fill — plan rules always, quotas only withbooking-policy-ruleson — see scheduling-bookings › Waitlist). Promotions and skips are announced after commit; inside a caller’stxthey ride onpendingPromotions/pendingWaitlistSkipsandsendPendingPromotionEmailspublishes them.cancellation_reason='entitlement_sweep'on revoked rows. No credit refund to the old subscription. - 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.
- 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 swappedplan_idand resetremaining_creditsin 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): thevariantparam 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 toactive), so reusingsubscription_ended’s “membership has ended” copy was simply wrong;payment_issuesays “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:
- Pending-cancellation horizon: if the member’s active sub has
cancelAtPeriodEnd=trueandcurrentPeriodEndset, booking a session starting aftercurrentPeriodEndis rejected with 409booking_beyond_subscription_end. Before this fix, a member could book indefinitely past their own chosen cancellation date. - Scheduled-change target-plan quota: if the sub has a
scheduledPlanIdset (mutually exclusive withcancelAtPeriodEnd— one pending action per sub) and the session starts aftercurrentPeriodEnd, the booking is allowed, but the daily/weekly cap checks are evaluated against the target plan’smaxBookingsPerDay/maxBookingsPerWeekinstead 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, becauseremainingCreditsdoesn’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):
| Trigger | Call site | effectiveAt |
|---|---|---|
| Immediate cancel (admin) | SubscriptionsService.applyImmediateCancel → sweepOnEnd | now |
| Period-end cancellation cron | SubscriptionsService.sweepDueCancellations → sweepOnEnd | the 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:
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.NOT EXISTSanother 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’scurrentPeriodEndfalls in the window. A9 refinement (FIT-254 review Wave A): “still-live” originally meant anyactive|past_due|pausedother sub. Apast_duesub whosenext_charge_dateisNULLis a zombie — retries stopped without ever reachingdebtor 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/pausedalways count;past_dueonly 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 inbooking-enforcement.util.ts. Unlimited plans (noclassCredits) spend nothing. A spend is refused when the ledger balance is 0. SubscriptionsService.refundCredit(late-cancel waiver) andadjustCredits(owner/admin) — both throughapplyManualCreditChange: the ledger balance moves by the amount, floored at zero, with a reason and an audit row.
Permissions matrix
| Action | Role | Source |
|---|---|---|
| Create plan | owner/admin | plans.service.ts:49 |
| Update plan | owner/admin | plans.service.ts:134 |
| Delete plan | owner/admin | plans.service.ts:181 |
| Purchase | active member | implicit (requireMembership) |
| Enroll member | owner/admin | subscriptions.service.ts:175 |
| Renew sub | sub owner OR owner/admin | subscriptions.service.ts:232 |
| Cancel sub (admin) | owner/admin | subscriptions.service.ts:302 |
| Request own cancellation | sub owner | subscriptions.service.ts (memberRequestCancellation) |
| Issue member cancellation notice | owner/admin, subscriptions/manage | subscriptions.service.ts (issueMemberCancellation) |
| Freeze / resume / adjust-credits | owner/admin | subscriptions.service.ts |
| Acknowledge / contact / set outcome / withdraw a retention case | owner/admin, subscriptions/manage | retention-case.service.ts (requireManage) |
| Preview plan change | sub owner OR staff subscriptions/manage — B13 (Wave B): manage required, no view carve-out | plan-change.service.ts:136-159 |
| Org change plan (all variants) | owner/admin, subscriptions/manage | plan-change.service.ts:899 |
| Member change own plan | sub owner | plan-change.service.ts:279-296 |
| Cancel scheduled change | sub owner OR owner/admin subscriptions/manage | plan-change.service.ts:391-414 |
| Manual charge / clear debt | owner/admin, payments/manage | manual-charge.service.ts:279-298 — see payments/behavior.md |
| List member payment methods | owner/admin, payments/view | payments/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 open | Gym opens later (presale) | |
|---|---|---|
| Staff assigns a plan | Card charged now; sub active | Nothing charged; sub scheduled |
| Member buys a plan | Hosted page charges; sub active | Hosted page tokenises (tokenOnly); sub scheduled |
next_charge_date | now + interval | opening 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 plainYYYY-MM-DDshown to the member and the desk, and thepayment_follow_uptask’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
| Trigger | Side effects |
|---|---|
| Plan price change | Affects 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. |
freezeSubscription | Bookings 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. |
resumeSubscription | Shifts BOTH current_period_end and next_charge_date forward by the frozen duration. Card-less subs keep a null charge date. |
updateChargeDate | Moves next_charge_date and current_period_end by the same delta. Audit row subscription.charge_date_changed. No charge is taken. |
| Member gives cancellation notice | Member 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 refund | Sub 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 boundary | plan_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. |
resumeCheckout | A 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
| Case | Behaviour |
|---|---|
| Member purchases free plan they already have active | 409 ConflictException (plans.service.ts:265). |
| Member retries an abandoned checkout | Pending sub reused; fresh payment page issued; no duplicate subscriptions row. |
| Webhook arrives for already-active sub | Activate transaction short-circuits; period not re-advanced if status was already active and the transition pending → active isn’t taken. |
| Owner cancels a paused sub | Allowed; transitions paused → cancelled. |
| Owner cancels a sub already cancelled | 400 Subscription is already cancelled. |
| Member requests twice | Second 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 negative | Clamped to 0 (subscriptions.service.ts:815). |
| Adjust credits on unlimited plan | 400 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=true | Freed 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 in | Buying 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 for | Refused: voidPendingSubscription finds the settled transaction and 409s cancel_pending_unsafe. |
Renewal cron hits a sub with no payment_method_id | Selector 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_creditswrites itsentitlement_ledgerrow in the same transaction. Relative changes (booking debit, cancel reversal, manual adjustment) insert the ledger row first withON CONFLICT (idempotency_key) DO NOTHINGand move the counter only if it applied. Absolute writes (purchase grant, period refill, plan swap, debt entry, cash cancel zeroing) callrecordCreditRebaseafter the write, which recordscounter − sum(delta)so the sum lands on the counter. Writes that set the counter to null (presale checkout, schedule repair) close the ledger with aresetto 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-creditstakes{ amount, reasonCode, note?, requestId? }andPOST …/members/:membershipId/refund-credittakes{ reasonCode, note?, requestId? }(zodadjustCreditsSchema/refundCreditSchema).requestIdis a client uuid per click: the ledger row is keyedadjust:<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; thebookings.refund_creditagent tool uses its tool-use id). Without it every call is its own change. Manual balance changes stay withsubscriptions: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 ledgeradjustment(with note and actor) and anentitlement.adjustedaudit row (categorybilling, actor, before/after balance, reason, note, requested amount, applied delta). The agent toolbookings.refund_creditrequiresreasonCodeand 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.withBalancesfor rows in hand,creditBalanceinside a transaction,creditBalanceSql()for SQL filters and lists).remaining_creditsis a mirror, moved in the same transaction; dropping it is a later step. A debit locks the subscription rowFOR NO KEY UPDATE, answers a key already on the ledger as a duplicate, decides on the ledger, then moves the mirror without a> 0guard: the lock already excludes a concurrent spend, and a guard could only let a drifted mirror overrule the ledger. Theentitlement-bucketsflag 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/resetrow should not exist (every writer grants at purchase; the backfill opened the rest;TestingServiceandscripts/seed-entitlement-mix.tsopen theirs throughopenUnopenedLedgers). If one is read, the reader falls back to its counter and reports it: a structured warning on every read, and a Sentry message taggedcheck=entitlement_ledger_unopenedwith 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 whereCRONS_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 theHEARTBEAT_URL_ENTITLEMENT_DRIFTheartbeat, findings or not. - Deploy. Order: migration
0172_entitlement_ledger(runs as the API’s RailwaypreDeployCommand) → 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 noreasonCode; reloading the page fixes it.scripts/backfill-entitlement-ledger.tsopens 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 nogrant/resetrow, 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 setremaining_creditswithoutrecordCreditRebase, or rows inserted by hand / by a seed. Fix the writer first, then open the affected ledgers withscripts/backfill-entitlement-ledger.ts(dry run, then--apply; it writesreset:<subId>:openingat 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_ledgerbysubscription_id, oldest first) against itsupdated_atand 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_DRIFTping — the scan did not complete (no worker withCRONS_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_bucketsrows:name,position(display order only),allowance(null = unlimited, 0 = the matching classes are not in the plan),period(day/week/month= the plan caps’ ownquotaWindowForwindows,window= the billing cycle,term= the anchor tocurrent_period_endfor packs) andscope(the D9 condition model;spacevalues 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,maxBookingsPerWeekandmaxBookingsPerMonthare 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 periodday/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);PUTthe whole list (bookingPolicy:manage, owner/admin): array order isposition, a quota missing from the list is soft-deleted, one without anidis created, all in one transaction. Refused 403{ code: 'feature_disabled' }while the flag is off. Oneplan.buckets_updatedaudit 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_bucketsrows. 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_idstays null (no foreign key). A quota’s usage is counted frombookings, 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/myand the staff/members/:membershipId/subscriptionscarrybucketBalances[]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 needscanManageSubscription/canPayand themanaged-membersflag (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_onlyadult’s own membership promotes them totrains(apps/api/src/memberships/participation.ts). Paying for a child never does.