Subscriptions & Plans — Behavior
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, unflagged)
Plan groups ship without a feature flag: cap enforcement and form resolution always read the DATA (never a flag), so gating only the write surfaces bought nothing once the feature was ready for every org.
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.
- Look up any existing subscription for
(membershipId, planId)with status in(active, pending, past_due, debt).active→ 409You already have an active subscription for this plan.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).
- 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(a), CRITICAL: this entire path was reachable with
member-plan-changeOFF — it’s the member-checkout row of the exact same feature every other plan-change entry point gates on that flag. Now checked fail-closed, before any read of the replaced subscription (mirrorsPlanChangeService.memberChangePlan’s “flag off → 403 before anything”). One check covers both the member’s own call and staff callingpurchaseon a member’s behalf. - 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/coach | Read a member’s subs. |
/members/:id/enroll | POST | owner/admin (+ payments:manage for paid plans) | enrollMember — charges the member’s saved card (FIT-287). Body { planId, chargeNow? }. |
/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/: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/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. Flag-gated, 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 | remainingCredits += amount (clamped ≥ 0). Refuses unlimited plans. |
Staff recording a member’s notice (§5) routes through
memberCancelAtPeriodEnd with onBehalfOfMember, not through the immediate
staff cancel — the notice is legally the member’s, so it earns a one-month
effective date, confirmation email, review task and written cancellation
form. This is the only way a period-end cancellation is scheduled: there is
no member-facing route (neither web nor mobile offers a cancel action), and
nothing un-schedules a recorded notice — a change of heart is an immediate
staff action on the dashboard. Two identities are in play and they are not the same person:
the actor (the authenticated staffer) lands in cancellation_requested_by,
the audit row and the staff_recorded_member_notice event source; the
subscription owner is who the membership_cancellation form is issued to
and who the review task’s linked_user_id points at. Resolving the second from
the caller assigned the member’s own legal cancellation document to the staffer.
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: sub →past_dueonly, period and credits are left untouched, 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).
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 (the primary “retry my declined card” case) |
cancelled | ✅ proceeds (bring a fully-ended sub back with a fresh period) |
active | 409 SUBSCRIPTION_ALREADY_ACTIVE — already current; renewing would double-charge the period the member is already paying for |
debt | 409 DEBT_CLEARANCE_REQUIRED — clear the debt via DebtService.clearDebt first, not this button |
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; null for packs/drop-ins, same as createSubscription) and failedChargeAttempts: 0.
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 flags, 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 (join-membership-release)
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.
With join-membership-release ON, PaymentMonitoringService 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 and remains gated by JOIN_MEMBERSHIP_RELEASE per 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 (crm-lead-identity, per-org, default OFF). 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 (saarku+lead2@gmail.com), whose board position then flip-flopped between Converted and Awaiting payment 16 times.
With the flag ON, 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 (same flag). 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 (join-shell-release)
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. Scheduled shell cleanup remains gated by JOIN_SHELL_RELEASE; 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, flag-gated per 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, unflagged.
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.
Unflagged. It shipped behind subscription-member-cancel-pending (per-org, default OFF), which reached 100% in prod; the gate is now gone entirely — SubscriptionsService.canMemberCancelPending, the canCancelPending parameter threaded through resolveMemberAction/resolveMemberActions, the flag key and the CANCEL_PENDING_NOT_AVAILABLE error code have all been deleted. Rolling a checkout back is now half of the decision every member gets (below), so a gate that could hide one of the two choices is no longer something the flow can express.
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 → ['update_card'], 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, and cancelling one was a dead end —
memberCancelAtPeriodEnd accepted it and stamped a one-month effective date,
but sweepDueCancellations only ever selected LIVE statuses, so the notice
never matured. The row sat scheduled + cancelAtPeriodEnd forever holding a
capped presale seat, nothing could undo it, and no
member surface (web profile/payments, mobile profile/payments) rendered a
cancel action for it at all.
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. |
memberCancelAtPeriodEnd now refuses scheduled outright with 409
use_withdraw so the dead end cannot recur. A staffer recording a notice
(onBehalfOfMember) is pointed at the staff cancel — that route handles 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.
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 | |
|---|---|---|---|
| Success | status=active, period advanced, attempts=0, credits refilled | payment_status='current' | none (receipt only via initial charge path) |
| Attempt 1–2 fail | status=past_due, attempts++, `nextChargeDate=now+[3 | 7]d` | payment_status='past_due' |
| Attempt 3 fail | status=debt, debt_amount_in_cents += price, debtSince=now, nextChargeDate=NULL | payment_status='debt' | debtWarningHtml |
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; not gated by booking-entitlement-sweep, inseparable from the plan-change flag). 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’s live price — 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'), .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-promotion emails it would have sent are withheld (see Notifications below) and returned as pendingEmails on the result instead. applyScheduledSwap calls sweepEngine.sendPendingPromotionEmails(sweepResult) immediately after the swap transaction has committed.
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
fires cancellationRequestApproved(refundIssued=false) — the "your sub has ended" receipt
emits payment.subscription_cancelled { source: 'period_end_cron' }
runs the booking-entitlement sweep (C4, flag `booking-entitlement-sweep`) as of the
cancellation's effective date (not "now") — subscriptions.service.ts, sweepOnEndKeyed 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='plan_change' — superseded by a plan change | 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.
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 | ✅ (implicitly clears the flag) | ❌ | pending_action_conflict (scheduled only) |
active + pending cancellation request | ❌ | ❌ | pending_action_conflict |
active + existing scheduled change | ✅ (overwrites the schedule) | ✅ (overwrites the schedule) | — |
past_due | ❌ | ❌ | outstanding_balance |
debt | ❌ | ❌ | outstanding_balance |
paused | ❌ | ❌ | resume_first |
pending | ❌ | ❌ | plain 400 “checkout still pending” |
cancelled | ❌ | ❌ | plain 400 “already cancelled” |
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; the live price at the boundary is charged (same rule as every renewal). The scheduled-change banner always shows the live price, not the price at scheduling time.
- A period-end notice recorded after a change was scheduled supersedes the schedule and clears it in the same write (see
memberCancelAtPeriodEnd).
B5(a) — mutual exclusion enforced at write time, not just read time (FIT-254 review Wave B): scheduleChange’s write (scheduledPlanId/planChangeScheduledAt) previously had no WHERE-clause guard of its own — the one-pending-action invariant was only checked earlier, by reading the sub and deciding (enforcePlanChangeEligibility’s pre-write check). A concurrent memberCancelAtPeriodEnd could still land in the gap between that read and this write. The write is now WHERE id=... AND cancel_at_period_end=false; zero rows (raced) → 409 PENDING_ACTION_CONFLICT instead of silently occupying both “pending action” slots at once. The mirror-image write, memberCancelAtPeriodEnd (subscriptions.service.ts), got the same treatment: WHERE id=... AND scheduled_plan_id IS NULL, same 409 on a miss.
Member self-serve (§4.5, flag member-plan-change)
PlanChangeService.memberChangePlan (plan-change.service.ts:266). Direction is classified server-side from computeDueNow — the member only submits newPlanId:
- Upgrade (
dueNow ≥ ₪1): delegates toPlansService.purchase({ replacesSubscriptionId: sub.id }), requiressuccessUrl/cancelUrl, returns{ mode: 'checkout', paymentPageUrl, dueNowInCents, subscription }.dueNowInCentsin the response is whatpurchase()actually computed/charged server-side at checkout-creation time — never the value resolved a moment earlier inmemberChangePlan(price could have changed between the two calls). - Downgrade/lateral (
dueNow < ₪1, including a free target plan): identical toscheduleChange— members never get an immediate downgrade. Returns{ mode: 'scheduled', effectiveAt, nextChargeInCents, scheduledPlanId }.
No approval loop (confirmed product decision). Abuse controls instead: the booking sweep (below) so quota can never be banked across a change; downgrades only apply at the boundary; upgrades always cost real money through checkout; at most one pending change per sub (a second call replaces the schedule).
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 (gated on member-plan-change) or staff with subscriptions/manage (gated on admin-plan-change) — a staff member previewing their own sub is allowed through if either flag is on. 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).
B13 fix (FIT-254 review Wave B, product decision per spec §8): this endpoint used to accept subscriptions view-or-manage, and the legacy permission matrix (rbac-v2 off, the default) grants coach view on subscriptions — so a coach with admin-plan-change enabled could preview any member’s plan-change proration under the default matrix, a carve-out spec §8 explicitly rules out (“coaches finance-blind, no exceptions”). Now requires manage regardless of matrix version; 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
DELETE .../subscriptions/:subscriptionId/change-plan/scheduled (PlanChangeService.cancelScheduledChange, plan-change.service.ts:364) — same route, dual authorization: sub owner (gated member-plan-change) or staff with subscriptions/manage (gated admin-plan-change). 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
- A pending cancellation request (
cancellation_requests.status='pending') blocks all plan changes on that sub (pending_action_conflict) — resolve the request first. cancel_at_period_end=trueblocks a scheduled change (pending_action_conflict) but allows an immediate change, which implicitly clears the flag (member chose to stay, on a different plan).- A pending scheduled plan change does not block a period-end notice at the DB level via
enforcePlanChangeEligibility(that check is one-directional, plan-change → cancellation state); the one-pending-action invariant for that direction is enforced incancellation-requests.service.ts(seesubscriptions-plans/qa-plan.mdfor the cross-check).
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.
Known UI inconsistency (W4, slated for W7 fix)
The staff scheduled-change badge (member-memberships-tab.tsx:283-287) shows planChangeScheduledAt (the notice/write timestamp) as its date, while the member-facing scheduled-change banner (scheduled-plan-change-banner.tsx:79) shows currentPeriodEnd (the actual effective date the plan swaps). These are two different dates — a change scheduled today for a period ending in three weeks shows “today” on the staff badge and “in three weeks” on the member banner. Documented as current behavior; a W7 fix will align both to currentPeriodEnd.
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 using the identical primitives
book()enforces (booking-enforcement.util.ts: UTC-day / Mon–Sun-UTC-week windows, the overlap rule, atomic credit deduction) — check order mirrorsbook(): overlap → daily cap → weekly cap → credits. newSubscriptionId = null(entitlement ending outright) → everything is revoked, nothing is classified.- Revocation = admin-cancel semantics: no cancellation-window check,
status='cancelled'+cancelledAt, one waitlist promotion attempt per freed confirmed seat (existing promotion logic, including skip-if-out-of-credits). 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. A belt-and-suspenders guard was also added directly in promoteFromWaitlist (booking-enforcement.util.ts): it now refuses to promote onto a session that has already started, regardless of caller — this should be unreachable from the sweep given the filter above, but it’s a two-line safety net shared by every caller of that primitive (including BookingsService.cancel/adminCancel’s own promotion path).
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 (subscriptionId = newSubscriptionId OR membershipId = <membership>) feeds both baselines, split in memory — the overlap interval set now covers every row returned (membership-wide), while day/week counts still only accumulate rows where subscriptionId === newSubscriptionId (unchanged, matches book()’s own per-subscription quota queries). 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 DEBT entry (payment_issue— the twosweepOnDebtcall sites,recurring-charge.service.ts/webhook-processing.service.ts). Debt is recoverable (a clear-debt charge brings the sub straight back toactive), so it previously 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. Waitlist-promotion emails (existing “You’re in!” template) are still 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, W7 audit item.
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(), flag booking-entitlement-sweep)
Two independent checks inside book(), both gated on the same flag and 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 (flag booking-entitlement-sweep)
Every path that terminates a subscription’s entitlements outright calls the same private helper (sweepOnEnd in subscriptions.service.ts:659, or the payments-module equivalent sweepOnDebt in recurring-charge.service.ts:423 / webhook-processing.service.ts:642) — all fail-closed no-ops when the flag reads anything other than a literal true:
| 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) |
| Renewal-cron debt entry (3rd consecutive renewal failure) | RecurringChargeService.handleChargeFailure → sweepOnDebt | now |
| Webhook debt entry (first-charge/renewal failure via provider webhook) | WebhookProcessingService.handlePaymentFailed → sweepOnDebt | now |
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.
Flag gating summary
booking-entitlement-sweep (PostHog, organization group, fail-closed) gates only C3 and C4 — the hardening call sites. The sweep engine itself, when invoked from inside a plan-change flow (immediate charge/comp, the checkout activation hook, the scheduled boundary swap), is not gated by this flag — it is inseparable from admin-plan-change/member-plan-change: a plan change without its sweep would itself be the quota-banking abuse hole the sweep exists to close.
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
SubscriptionsService.deductCredit / refundCredit / adjustCredits (subscriptions.service.ts:721).
deductCredit(membershipId)— finds active sub. If plan has noclassCredits(unlimited), no-op. OtherwiseremainingCredits--; throws if zero.refundCredit(membershipId)— finds active or paused sub. Same no-op rule.adjustCredits— owner/admin only.Math.max(0, remaining + amount).
Bookings call these; the implementation lives here.
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 |
| Cancel-at-period-end | sub owner | subscriptions.service.ts:486 (requireMemberOwnedSubscription) |
| Freeze / resume / adjust-credits | owner/admin | subscriptions.service.ts:628, :675, :785 |
| Submit cancellation request | sub owner | cancellation-requests.service.ts:75 |
| Approve / reject cancellation request | owner/admin | cancellation-requests.service.ts:362 |
| Preview plan change | sub owner (flag member-plan-change) OR staff subscriptions/manage (flag admin-plan-change) — B13 (Wave B): manage required, no view carve-out, regardless of matrix version | plan-change.service.ts:136-159 |
| Org change plan (all variants) | owner/admin, subscriptions/manage, flag admin-plan-change | plan-change.service.ts:899 |
| Member change own plan | sub owner, flag member-plan-change | plan-change.service.ts:279-296 |
| Cancel scheduled change | sub owner (flag member-plan-change) OR owner/admin subscriptions/manage (flag admin-plan-change) | plan-change.service.ts:391-414 |
| Manual charge / clear debt | owner/admin, payments/manage, flag admin-card-on-file | 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
both coach-N in both matrices). Resolved (B13, FIT-254 review Wave B):
“Preview plan change” previously accepted subscriptions/view-or-manage,
and PERMISSION_MATRIX_LEGACY.subscriptions is V for coach (matrix.ts:176,
“coach can read one endpoint today”) — so under the legacy matrix (rbac-v2
off, the default) a coach with admin-plan-change enabled could call the
read-only preview endpoint for any subscription in the org, a carve-out FIT-254
§8 explicitly rules out. Preview now requires subscriptions/manage
regardless of matrix version, closing this under both matrices — no
rbac-v2 dependency needed.
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)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. Handled by handlePresaleChargeFailure, not the
renewal failure path. The sub stays scheduled through the 3/7/14 retry ladder
and is then cancelled (presale_first_charge_failed) rather than escalated to
debt. It must never become past_due: bookings.service.ts treats past_due
as entitled, which would hand the gym to someone whose first charge never
cleared. Nothing was conferred, so there is nothing to collect.
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.
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 plan) | Sub active, provider untouched. This is how a comp membership is expressed now that assignment bills. |
enrollMember (gym not open yet) | Sub scheduled, nothing charged, next_charge_date = opening day. chargeNow: true overrides to charge today. |
freezeSubscription | Bookings refuse credits until resumed. Charge cron skips paused subs (selector requires status IN ('active','past_due','scheduled')). |
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. |
| Cancellation request created | 2 emails + 1 high-urgency task. |
| Cancellation request approved with refund | Sub canceled, refund flow runs, member email, observability payment.cancellation_request_approved. Refund task created if provider 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 email, no form, no task, no refund — nothing was charged. |
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 cancels twice | Second request — guard at cancellation-requests.service.ts:84 rejects with 400 request is already pending. |
| 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, which withdraws it outright. New ones cannot be created — memberCancelAtPeriodEnd refuses scheduled 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. |