Payments — Behavior
Money is paranoid. Every flow below is documented from the source in apps/api/src/payments/.
Provider configuration
An org configures one gateway via POST /organizations/:orgId/payment-config (payment-provider-config.controller.ts:29, owner/admin only). The body is { provider, credentials, config? } (configure-provider.dto.ts). credentials is a Record<string,string> encrypted with AES-256-GCM into payment_provider_configs.encrypted_credentials (credential-encryption.service.ts); the non-secret config is stored as plaintext JSON.
Taikan-managed terminals (Cardcom, FIT-286)
Settings → Payments opens on a mode choice rather than a provider list (provider-config-dialog.tsx):
| Mode | What happens | Credentials |
|---|---|---|
| Taikan managed | Taikan opens a Cardcom terminal in the gym’s name under its dealer agreement. | Never asked for — they come back from Cardcom. |
| Bring your own | The existing POST /payment-config credentials path. icredit, morning, cardcom (for a gym that already holds a terminal). | Owner pastes them. |
The managed path is explain → apply → one-time login:
- Explain. A dedicated step states that Cardcom underwrites the application at its own discretion, that approval takes days, and that money settles from Cardcom straight to the gym’s bank — Taikan never holds funds. No fields on this step.
- Apply.
ManagedTerminalFormcollects ~15 fields (business, owner, bank account) andPOSTs to/organizations/:orgId/payment-config/provision. The builder expands these into the ~70 CardcomNewCompanyasks — seecardcom-provisioning.builder.ts. - One-time login. Cardcom returns a dashboard login for the owner. It is never persisted, so the dialog refuses to close on that step until the owner acknowledges it.
Rules the code enforces because money depends on them:
- A provisioned terminal is not a chargeable terminal. The row is written
status = 'pending_kyc'withisActive = true.getDecryptedCredentialsfilters onstatus = 'active', so every charging path fails closed at once. Members see a gym with no shop; staff see a pending badge and keep authoring priced plans. Clients must gate payment actions onstatus, never onisActive: shop buy and plan-step purchase, the pricing-editor purchase readiness hint,canChargeand temporary pricing on the member’s plans tab, refund execution and external-billing checks all read the payment config’sstatus/providerdirectly. Plans authoring (plans-content.tsx,plan-detail-page.tsx) deliberately readsisActive, so a pending-KYC owner keeps building priced plans. - Whether to show billing at all is a different question. Web visibility — the members-screen plan column, payment filter and money slices, Enroll / Manage plans, the at-risk callout, the overview revenue tile, payments KPI cards, finance Action Center rules and the setup-guide payment steps — reads one helper,
hasBilling()(apps/web/src/lib/billing.ts, hookuseBilling()): payment configstatus = 'active'or any plan exists, archived (GET /plans?active=false) included, so a cash-only gym counts. The archived read is lazy: only an org known to have neither an active terminal nor an active plan asks for it, so an org that bills never issues it (the overview page prefetches it server-side for exactly that org). Unknown counts as billing: while loading, nothing is hidden. Every staff role reads the payment config (the API serves it to any member, credentials redacted); only managing it is owner-only, so the read is never gated on thepaymentConfigpermission. There is no server-side field for it. - One application at a time. Every successful call opens a real company at Cardcom and nothing downstream deduplicates them, so
provisionTerminalthrowsConflictwhile apending_kycconfig exists. A rejected application can be resubmitted.
KYC volume estimates (min/max/average transaction, monthly count) are derived server-side from the org’s active plans and member count, with fallbacks for a gym that hasn’t priced anything yet. The owner is never asked — Taikan already knows the numbers, and a guess on an underwriting form is worse than a derived one. The DTO still accepts an explicit override.
Double-provisioning guard
The mode choice in settings and the provision endpoint are available to every org. Provisioning is irreversible: provisionTerminal refuses when the org already holds a pending_kyc application, which is the guard that stops a double-click opening two real companies at Cardcom.
Debugging a rejected application
Both Cardcom calls log the full exchange at log level: the outbound request (NewCompany → POST … with the body pretty-printed, GetCompanyStatus → … with the credential query params masked) and the raw response (← HTTP <status> <statusText> followed by the untouched body).
Every failure — a rejection, a non-JSON reply, an unreachable provider — is also captured to Sentry as a structured issue (integration:cardcom, operation:NewCompany, orgId, cardcomResponseCode) with the request and response in extra. Structured fields rather than the message, because Sentry truncates and groups on messages and this exchange exists to be copied verbatim into a Cardcom support ticket. Info-level request/response logs additionally reach Sentry Logs in production via Sentry.pinoIntegration (instrument.ts:55).
Responses are read as text before parsing. Cardcom answers auth and routing failures with HTML or a bare string, and parsing straight to JSON turned those into an opaque “could not reach the payment provider” with the actual reason discarded — the one thing that can’t be forwarded to their support. A non-JSON body now surfaces as Payment provider returned a non-JSON response (HTTP 401): ….
The request body is redacted by default («redacted:<length>», since the length alone often answers the question). CARDCOM_PROVISIONING_DEBUG=true prints it verbatim — for a local box with test data, never production.
Waiting out the KYC review
The status call is GET only, keyed on terminalNumber plus Cardcom’s integer companyNumber. POST to the same path answers 404, so the article’s JSON-body example is wrong and there is no POST fallback.
companyNumber is CompanyNumber from the NewCompany response — not CompanyInternalID. They are two different fields with two different values on the same response; Cardcom’s own documentation example pairs CompanyInternalID: 21348 (“מספר חברה שנפתח בקארדקום”) with CompanyNumber: 21351 (“מספר פנימי של בית העסק”), and only the latter is what this endpoint binds to. CompanyInternalID additionally comes back as a UUID in practice, so sending it failed ASP.NET model binding with The request is invalid. before any handler ran (prod, 2026-08-12, FITKIT-BACKEND-3V) and took the status sweep down with it. Every read of the value now goes through readCompanyNumber (@taikan/shared), which returns a digits-only string or undefined; a terminal whose stored config has no company number is never sent to this endpoint at all.
Cardcom confirmed (2026-08-11) that there is no approval webhook: the gym’s contact person gets an SMS, and dealers poll GET /api/v11/CompanyOperations/GetCompanyStatus. Taikan watches for approval three ways, all landing on the same code path (managed-terminal-status.service.ts):
| Trigger | Who | What it does |
|---|---|---|
@ScheduledJob(…, '30 * * * *') | system | Polls every pending terminal whose org has managed-terminal-kyc-poll on. Hourly on purpose — the wait is measured in business days and the dealer API is not ours to hammer. |
POST …/payment-config/managed-status/refresh | owner/admin | Same check, on demand, and never flag-gated. Exists because the SMS reaches the owner before any tick does; the settings card shows a Check status button while pending. |
POST …/payment-config/managed-status | platform admin | The manual override, and the only way to close a rejection — a declined application simply never becomes IsDone. |
The sweep is gated on managed-terminal-kyc-poll (per-org, organization group, default OFF). It ran unflagged until 2026-08-13, when it was removed for failing hourly on the wrong company identifier (FITKIT-BACKEND-3W), and came back on 2026-08-16 with the identifier corrected. The flag is the rollout control for a fix nobody has yet watched Cardcom accept: turn it on for one gym, confirm GetCompanyStatus answers ResponseCode: 0, then widen. Off, unset, or unevaluable (PostHog unreachable) leaves that org exactly where it is today — on-demand only. Two things are skipped without contacting Cardcom at all: an org whose flag is not a definite true, and a terminal with no stored companyNumber, which can never produce a successful query no matter how often it is retried. CRONS_ENABLED still gates the tick itself, as for every other cron.
What the status check reads:
ResponseCodedescribes the query, not the application. A non-zero code leaves the terminal pending and changes nothing.IsDone: trueis the approval signal. On approval the charging credentials are re-encrypted from that response (APIUserName/APISecret) rather than trusting whatNewCompanyreturned, the status flips toactive, and only then does the activation email go out.DocumentsErrorResults/KycErrorResults/CompanyErrorResults/UserErrorResultsare flattened intostatus_reasonand shown to the owner on the pending card as “still needed” — Cardcom’s four-way grouping is its own filing system, not something an owner should have to parse.
Morning (morning.co.il, formerly Green Invoice)
Morning credentials are { apiKeyId, apiKeySecret, pluginId, webhookSecret } (read in morning.provider.ts:186, :493, :953):
| Field | What it is | Where to get it |
|---|---|---|
apiKeyId / apiKeySecret | API key pair → OAuth client_id / client_secret (morning.provider.ts:186). | Morning dashboard → API keys. |
pluginId | Identifies the payment plugin used to open hosted pages (morning.provider.ts:493). | See lookup below. |
webhookSecret | Compared against the inbound webhook secret/webhookSecret body field (morning.provider.ts:953, :996). Note the registered signature check still returns true regardless — see README gaps. | Owner-chosen value, set when registering the static webhook URL. |
Getting the pluginId (manual, today): Morning exposes no labelled field for it. To find it:
- Log into the Morning dashboard and open
/market/payments. - Open DevTools → Network and locate the request to
/api/v1/plugins. - In that response, the plugin’s
idis thepluginIdto put in the Taikan config.
This is a developer-level step gym owners can’t be expected to do. Planned direction (not built): automate the lookup with an agent — authenticate into Morning, capture the
/api/v1/pluginsresponse, extract theid, and populate the config automatically as part of self-serve Morning onboarding. Design TBD (Morning auth, headless browser vs. their API, where the agent runs).
Payment transaction state machine
transaction_status enum in libs/db/src/lib/schema/enums.ts:173.
| From | To | Trigger | File |
|---|---|---|---|
| new | pending | Charge initiated, provider page issued | payment.service.ts:176 (upsertPending) |
pending | completed | payment.completed webhook OR verify-return | webhook-processing.service.ts:121 |
pending | failed | payment.failed webhook OR adapter returns success=false | webhook-processing.service.ts:325 |
pending | cancelled | Subscription cancelled with in-flight pending charges | platform-billing.service.ts:749 (mirror pattern) |
completed | refund_pending | refund() against manual adapter — opens manual_refund task | payment.service.ts (openManualRefundTask) |
completed | refunded | refund() against automatic adapter once completed refunds add up to the whole sale, OR refund.completed webhook | payment.service.ts (processAutomaticRefund), webhook-processing.service.ts:528 |
completed | refunded | Cash credit notes add up to the whole sale (a partial credit leaves it completed) | cash-credit-note.service.ts (flipWhenFullyCredited) |
refund_pending | refunded | Owner closes manual_refund task with externalReference (refused for cash-notice reservations) | payment.service.ts (completeManualRefundTask) |
Refund rows (type = 'refund') have their own small lifecycle. A cash-notice reservation is written refund_pending by settleCashCancellation and becomes completed only through Confirm cash returned (settle-cash-refund); every other cash credit row is inserted completed. See Cash credit notes.
Terminal: completed, failed, refunded, cancelled. refund_pending is the only middle state where additional human action is required.
Subscription state machine (payment-driven)
subscription_status enum in libs/db/src/lib/schema/enums.ts:155. Owned by subscriptions/ but mutated heavily here.
pending ──(webhook payment.completed)──► active
│ ▲ │
│ └──(payment.failed: txn → failed, ├──(renewal declined | card unusable)──► debt
│ subscription STAYS pending) │ │
│ │ (retry or full payment) │ (day N, lifecycle)
├──(member resume-checkout)──► pending │ ▼
│ (fresh hosted page, same row) ◄──────────────── cancelled (non_payment, debt kept)
│ │ (full payment restores)
└──(24h sweep | owner void-txn | │
staff void-pending | │
member cancel-pending)──► ├──(admin freeze)──► paused
cancelled (revivable) │
└──(admin cancel | cancel-at-period-end cron)──► cancelled
scheduled ──(member withdraw | repair pass)──► cancelled (presale_withdrawn)
scheduled ──(first charge declined | card unusable)──► debt (presale starts that day)
active ──(lifecycle: cash lapsed | pay-later due)──► debt-
Every return leg carries
sub=<subscriptionId>, not just the success one (withSubParaminpayment.service.ts, idempotent so the mobile bridge’s ownsubisn’t duplicated, and Morning’sappendQuerynow skips a key it finds). The cancelled/failed page has to offer a decision about a SPECIFIC checkout, and without the id it has nothing to act on. -
First-purchase failure leaves the subscription
pending. Only the transaction is markedfailed(webhook-processing.service.ts,handlePaymentFailed), and apayment.activation_failedevent is emitted withreason: 'first_charge_failed'. It used to flippending → cancelledwith NOabandoned_checkoutmarker, which made a declined card a dead end in both directions: nothing for the member to resume, and nothing a late success webhook could revive. The charge failed; the checkout did not. If the member never comes back, the 24h sweep releases the row anyway — and does it with the marker. -
Distinct failure vs. cancel return URLs. Cardcom pointed
FailedRedirectUrlandCancelRedirectUrlat the samecancelUrl, so a declined card told the member they had cancelled. The hosted-payment input now carries an optionalfailedUrl(PaymentPageRequest), derived fromcancelUrlby flippingstatus=cancelled→status=failedwhen the client doesn’t send one (payments/checkout-urls.ts,deriveFailedUrl). The mobile deep-link bridge forwards the param untouched, sostatus=failedsurvives into the app. Morning is deliberately unchanged: its API has a singlefailureUrlused for both legs, so pointing it at the failed URL would mislabel a walk-away. -
Abandoned checkout release — no webhook of any kind ever arrives (the buyer just closed the tab): four callers converge on the same money-safety guard (
PaymentMonitoringService.voidPendingSubscription/voidPendingTransaction— no settled transaction, no in-flight charge) to flippending → cancelled, stampedcancellationReason='abandoned_checkout'so a late payment still revives it (webhook-processing.service.ts’srevivingAbandonedCheckout):sweepAbandonedCheckoutscron (payment-monitoring.service.ts), on whichever fuse applies:- 24h idle normally. Idle is measured from the newest OPEN attempt (
payment_transactionsstillpending), falling back tosubscriptions.created_at— anchoring on the subscription alone would sweep a two-day-old row the member had just re-opened viaresume-checkout, cancelling it while they looked at the payment form. - 1h idle when the provider’s last word was a decline (a
failedtransaction and nothing open). The 24h wait exists to protect a member who might still be on the payment page; a refusal is the provider saying they are not, and making a capped presale seat wait out a day for a bank’s decision protects nobody. Afailedrow followed by a newerpendingone is a member on their second card — the long fuse applies again. - Candidates are ordered oldest-first so the 200-row batch cannot fill with young rows and starve genuinely abandoned ones.
detectStuckPendingstill sees declined rows but emits themoutcome: 'ignored'so they do not page — they are pending on purpose now;
- 24h idle normally. Idle is measured from the newest OPEN attempt (
- owner/admin manual dismiss,
POST /payments/:txnId/void(payment-monitoring.service.ts,voidPendingTransaction); - owner/admin dismiss from the member page,
POST /organizations/:orgId/subscriptions/:id/void-pending— the same thing keyed by subscription id, because that screen renders subscriptions and a retried checkout has several transaction rows; - the member themselves,
POST /subscriptions/my/:id/cancel-pending(subscriptions-plans/behavior.md,memberCancelPending).
All four stamp the same reason. What separates them is
cancellation_requested_by: the member’s own rollback carries the member, the staff routes carry the staffer, the cron carries nobody. That single column is what decides whether the row is hidden from the member’s own list (see the decision flow below) — never the reason, which must stayabandoned_checkoutfor revival to work.
Checkout decision: the member came back without paying
The gap this closes: nothing happened server-side when a member closed the hosted form. They were left holding a “complete payment” membership with no button that worked, a pending transaction, and (on a capped presale) a held seat. The 24h sweep was the only exit.
| Step | Endpoint | Effect |
|---|---|---|
| Report the landing | POST …/subscriptions/my/:id/checkout-return | Telemetry only, mutates nothing. Emits payment.checkout_cancel_returned + payment.return_landed. The provider fires no event for a form nobody submitted, so without this every drop-off looked like a webhook outage. |
| Resume | POST …/subscriptions/my/:id/resume-checkout | Fresh hosted page for the SAME pending row, via PlansService.resumeCheckout → purchase(resumeSubscriptionId). Re-runs the plan-availability, seat, compliance-form and intro-pricing gates by construction. Emits payment.checkout_resumed. |
| Roll back | POST …/subscriptions/my/:id/cancel-pending with { intent: 'regret', source } | Same guard, same abandoned_checkout reason, plus cancellation_requested_by = member. Row hidden from GET …/subscriptions/my, kept for staff. The decision is recorded on the abandoned charge attempt’s payment_transactions.metadata.checkoutCancel (subscriptions has no metadata column, and the two candidate columns already mean other things). |
memberActions on every member subscription response lists exactly what is on offer (['complete_checkout','cancel_pending'] for a live checkout), so no client re-derives it. The legacy singular memberAction is unchanged for deployed mobile builds.
- Renewal failure puts the row in debt on the first decline; retries follow f(N) and
subscriptions.failed_charge_attemptscounts declines. An expired or missing card does the same without a charge attempt (markCardUnusable). See Unpaid payment. - Membership
payment_statusmirrors the subs:current | debt(past_dueis legacy, no longer written). Bookings do not read it: they judge every live subscription through the billing gate.
Charge flow (hosted page)
PlansController.purchase→PlansService.purchase(apps/api/src/plans/plans.service.ts:200).- Resolves/creates a
pendingsubscription. Reuses pending row on retry (no duplicate inserts). - Calls
PaymentService.createHostedPayment(apps/api/src/payments/services/payment.service.ts:92):- Resolves the active provider config (decrypts credentials).
- Calls
adapter.createPaymentPage(...). - Upserts a pending
payment_transactionsrow keyed bysubscriptionId(payment-transaction.service.ts:154) — never duplicates on retry.
- Returns
{ paymentPageUrl, processId }. The client hard-redirects. - Provider POSTs the webhook →
payment-webhook.controller.tsvalidates signature →WebhookProcessingService.processWebhookEvent(webhook-processing.service.ts:42):- Looks up the pending txn by
providerTransactionId; if absent, falls back tocompletePendingBySubscriptionId. - Activates the sub inside a DB transaction: status
active, advancescurrentPeriodStart/End, refillsremainingCredits. - Stamps the card token to
member_payment_methods(replace existing, mark old inactive —replacePaymentMethod).
- Looks up the pending txn by
- On user return to
successUrl, the web app callsPOST /verify-return(payment.controller.ts:42). Idempotent fallback: re-fetches Cardcom’sGetLpResultand replays the same webhook handler.
Native-app return URLs go through a bridge. Providers reject custom-scheme return URLs (Morning fails the whole /payments/form call with 1103 כתובת אתר לא תקינה), so the mobile app must not send taikan://… as successUrl/cancelUrl. Instead it sends https://<api>/payments/app-return?to=<app-path>&… (app-return.controller.ts, @Public), which 302s to taikan://<app-path> forwarding every other query param — including sub=, appended later by payment.service.ts:132 / morning.provider.ts:448. The taikan:// redirect is what closes the app’s openAuthSessionAsync browser session. to is restricted to relative in-app path segments, so the endpoint can’t be used as an open redirect. Applies to plan purchase and card registration (both build the bridge URL via paymentReturnUrl in the mobile repo’s src/lib/api.ts).
Recurring charge flow
This is the provider-direct flow, used only for organizations whose active provider is not Cardcom. Every Cardcom organization’s rows, and any obligation already persisted on the protected protocol, are collected as described in Billing resilience instead; the daily sweep below routes them there per organization.
Cron 0 2 * * * in RecurringChargeService.handleRecurringCharges (recurring-charge.service.ts). See subscriptions-plans/behavior.md’s Renewal cron section for the full scheduled-plan-change boundary-swap detail (A2/A3/A4/A8); summarized here:
- A5 advisory lock (FIT-254 review Wave A): the whole sweep first attempts
pg_try_advisory_lockon a fixed key; if another run already holds it, this run logs a warning and returns immediately — no SELECT, no charges.FOR UPDATE SKIP LOCKEDalone only protects individual rows for the duration of their own implicit transaction, not the whole method, so two overlapping invocations (scheduled tick vs. the test-only manual-trigger endpoint) could otherwise both select and charge the same due subscriptions. SELECT … FOR UPDATE SKIP LOCKEDonsubscriptions WHERE (next_charge_date <= now OR (scheduled_plan_id IS NOT NULL AND next_charge_date IS NULL AND current_period_end <= now)) AND status IN ('active','past_due','debt') AND payment_method_id IS NOT NULL OR scheduled_plan_id IS NOT NULL(recurring-charge.service.ts). A2(b) fix: the boundary OR-arm is what makes a comped (card-less) sub’s schedule reachable at all — such a sub’snext_charge_datestaysNULLforever (it’s never billed), so the originalnext_charge_date <= nowpredicate alone could never select it once its schedule fell due.- Per row: if
scheduled_plan_idis set, swap the plan first (see the boundary-swap section linked above) — A2(a): a card-less sub after the swap is billed via NEITHERgetActivePaymentMethodNOR any charge attempt (it’s a swap-only visit that advances its own period in the swap transaction); a carded sub proceeds as below. - Insert
pendingtxn first (the “record before charge” invariant), thenadapter.createCharge(token, …), then update txn status. - Success →
handleChargeSuccess: advance period, reset counters, set membershippayment_status = current. A3 exception: a row that went through the boundary swap this tick does NOT getremainingCreditsrefilled to the raw allotment — the swap’s in-place sweep already set it to (allotment − kept-booking deductions); refilling would erase that. - Failure →
handleChargeFailure: the first decline enters debt (SUBSCRIPTION_DEBT_OPENED, notice by event); a declined retry movesnextChargeDateto the next retry instant and sendspaymentFailedEmail. A success on a debt row clears the debt without moving the period. See Unpaid payment.
The “pending row first” guarantees that a process kill between provider call and DB update leaves a pending row that the next cron tick (or the reconciler concept) can resolve — never a charged-card-with-no-record.
Final cancellation payment
Notice suppresses normal renewals, not the proportional final bill. Before the
regular recurring selector, CancellationTailSettlementService visits scheduled
cancellations whose paid-through boundary has arrived. The cancellation sweep
also runs this as a catch-up before ending memberships.
- Tail coverage is exactly
[currentPeriodEnd, cancellationEffectiveAt). Charge cents are floored once from the actual paid interval and its effective paid price, with integer arithmetic; no hardcoded 30-day denominator and no full-renewal-plus-refund. Equal coverage is a no-op; prepaid excess retains the existing proportional refund path. - New money movements require the existing per-org PostHog flag to be a
definite
true; OFF/unset/unevaluable does not collect. Manual, external and off-platform payments remain organization responsibility, and a different subscription’s saved card is never used. - The existing saved-token provider adapter and
PaymentTransactionServicecreate arecurringintent before charging, withsource: cancellation_tailand a uniquecancellation-tail:<subscriptionId>:<effectiveAt ISO>key.updateStatuspreserves provider response, transaction identity and the normal charge audit. The existing cancellation-settled audit and advisory lock coordinate refund-versus-charge direction. - Verified success changes coverage/next charge boundary to the effective date only: no full period, credits refill, intro-cycle consumption or renewal event. The cancellation date and booking horizon never move.
- A verified decline puts the row in debt (
charge_failed, no retries) with the tail amount, or adds it to a debt already open, extends the period to the effective date, clears the next charge and opens a localized owner review task. Unknown/transport outcomes stay pending and also require owner review. The claimed intent is never charged again automatically, regardless of failure count; ordinary renewal cannot become its retry. - Exact-correlated, amount/currency-matched webhooks reconcile this transaction before the generic activation path. Coverage/debt and the applied marker are atomic and recoverable after a crash, even after rollout is disabled or the membership has ended. Late success never reactivates a cancelled row. Webhook lock contention returns a retryable error, not silent acknowledgement. Notice withdrawal is serialized with settlement and refuses unresolved final payments, preventing a tail followed by an overlapping normal renewal.
See subscription settlement for the September 5 / September 26 / October 5 worked example and access rules.
Refund flows
Entry: PaymentService.refund(orgId, txnId, request).
Desk-recorded money first. A transaction with provider = 'manual' (cash, transfer, cheque, Bit) is routed before any provider logic to ManualPaymentService.refund, which is a credit note through the cash credit core and requires request.idempotencyKey (400 manual_refund_idempotency_required without one). Everything below applies to gateway transactions only. See Cash credit notes.
Refused outright on the external tier. Before any gateway routing, an org whose active provider config is external (ADR-0017) gets a 409 carrying PaymentErrorCodes.EXTERNALLY_BILLED. That tier has no Taikan adapter and cannot have one — the whole point is zero provider API access — so a refund there previously reached registry.getOrThrow('external') and surfaced as an unhandled 500. ADR-0017 had already decided these refunds happen in the provider’s portal (“Given up”); this is that decision as a guard. Keyed on the active provider alone, deliberately not through isExternallyBilled(provider, plan): that helper also requires the plan to carry a payment link, which is the right question for subscription lifecycle actions but the wrong one here — the adapter is missing whatever any plan says. The owner UI matches the guard exactly (transactions-table.tsx, member-payments-tab.tsx): no refund button, plus a banner saying why, so an absent action doesn’t read as a permissions bug.
Otherwise, three gates decide automatic-vs-manual, evaluated cheapest-first and OR’d together — any one of them routes to the automatic path:
| Gate | Where | Meaning |
|---|---|---|
adapter.getRefundCapability() === 'automatic' | in-memory | The adapter declares a verified refund API. No provider does this yet. |
isManagedTerminal(orgId) | one DB read | Taikan opened this terminal under its own dealer agreement (FIT-286), so the refund is ours to make. |
RefundCapabilityService.canRefundThroughProvider | in-memory | A bring-your-own Cardcom terminal: the refund is tried through the API, falling back to the manual task when the provider refuses. |
The third gate exists because the first two miss the hybrid case: a gym on its own Cardcom terminal. isManagedTerminal is false (no provisioning marker to find) and Cardcom reports manual, so the refund became a manual task and never reached Cardcom — while the transaction sat in refund_pending, which reads to an owner like a refund that worked. Observed in prod 2026-08-13 on org 8bb3905f.
Only cardcom is eligible (REFUNDABLE_PROVIDERS), so a refund can never reach an adapter whose refund() is a stub. A charge made under a different provider than the active config stays on the manual task.
Note getRefundCapability() returning manual for Cardcom means “unverified against a live terminal”, not “unimplemented” — CardcomProvider.refund calls v11 RefundByTransactionId and handles its response codes.
refundExecution on the payment-config response
GET /organizations/:orgId/payment-config now carries refundExecution: 'automatic' | 'task' | 'unavailable' (RefundExecution, libs/shared/src/lib/schemas/payment.schema.ts), computed by RefundCapabilityService.resolveExecution(orgId) and returned alongside supportsPresale (payment-provider-config.controller.ts, getConfig). It answers “would clicking refund on any of this org’s payments reach the provider automatically right now” — before any specific transaction is even in view — using resolveAutomatic, the exact same gate combination PaymentService.refund itself evaluates for a real transaction, so the two can never disagree about the same org:
automatic— the API call goes straight through.manual(desk-recorded) transactions never reach an adapter whatever this says: they are reversed by a credit note at once.task— a human has to move the money in the provider’s own portal; the client shows that expectation before the confirm.unavailable— no active provider, the active one isexternal(ADR-0017), or the active provider simply has no registered adapter (e.g.test) — none of which Taikan can call at all. Resolved viaregistry.get(), nevergetOrThrow(), so an unregistered-but-configurable provider type reads asunavailableinstead of 500ing the whole payment-config response.
Web reads this defensively (transactions-table.tsx, member-payments-tab.tsx) so an older cached API build with no refundExecution field simply shows no task-provider note rather than erroring.
Refunding across a provider switch
refund() resolves credentials for the transaction’s own provider column, not blindly the org’s current active config — an org that switched gateways still owns refunds against transactions from before the switch. When txn.provider differs from the active config, PaymentService looks up historical credentials via PaymentProviderConfigService.getCredentialsForProvider(orgId, txn.provider) (most-recent status='active' row on file for that specific provider, even though it isn’t the active one today). If none exists, the refund is refused before any adapter call with 409 PaymentErrorCodes.REFUND_PROVIDER_MISMATCH — a coded error (refund_provider_mismatch) the web layer localizes, carrying provider/activeProvider as separate structured fields rather than baked into the message. If a credentials row IS on file but the registry has no adapter registered for that provider (an org that migrated off a tier still carries its old config row forever — configure() only flips isActive, and a provider like external never had an adapter to begin with), the refund does not 500: it falls through to the same openManualRefundTask a pre-migration refund would have used.
This closes a real hole: previously refund() always resolved the org’s active provider’s adapter and credentials regardless of which provider actually processed the transaction, so a refund attempt after a provider switch fired today’s adapter and credentials at yesterday’s providerTransactionId — reversing the wrong thing (or nothing) while still reporting success. The isManagedTerminal automatic-refund shortcut is skipped entirely on a mismatch for the same reason: it only ever answers for the org’s ACTIVE config, which by definition isn’t the provider a mismatched refund is going through. issueCreditDocument (the follow-up חשבונית זיכוי call) takes the same resolved { adapter, credentials } pair processAutomaticRefund already settled on, rather than re-resolving the org’s active config itself — otherwise a mismatch refund’s credit document would be attempted against today’s gateway with yesterday’s refund deal id.
Overdue refund-task sweep
RefundTaskSweepService (apps/api/src/payments/services/refund-task-sweep.service.ts), daily @ScheduledJob(…, '15 3 * * *') — after the 02:00 recurring-charge and 02:30 cancellation crons, on its own beat since it touches neither’s rows. Unflagged. Every manual_refund task that is not soft-deleted, whose status isn’t completed/cancelled (so a task staff already started — in_progress — still escalates like an untouched one), whose dueDate has passed, and whose priority isn’t already urgent gets bumped to urgent, and payment.refund_task_overdue is emitted per task. Idempotent by construction — a task already at urgent priority is excluded from the query, so an open overdue task is bumped (and re-emits) exactly once, not every tick.
Before this, a manual_refund task’s due date passing changed nothing: the task stayed at priority='high', indistinguishable on a dashboard from every other unclosed high-priority task, while the member’s money sat in limbo indefinitely with no escalation signal anywhere.
Automatic capability (no provider declares this yet; reached via the other two gates)
processAutomaticRefund (payment.service.ts):
- Inside a DB transaction, lock the sale, refuse while a refund task is open on it (400
Refund already in progress for this transaction), and check the amount against the cap (what is left after completed and pending refunds; 409refund_amount_exceeds_cap{ capInCents }, nothing written, provider not called). - Insert the
refundrow aspendingbefore calling the provider, carrying the original’ssubscription_idandplan_idandmetadata: { source: 'automatic_refund', refundedTransactionId, reason }, so a parallel refund sees it in the cap and refund totals per sale are a query. - Call
adapter.refund(...)with the adapter and credentials resolved for the transaction’s own provider, then settle the row (completed/failed). The Cardcom adapter returnsoutcome: 'refused'for a non-zeroResponseCodeandoutcome: 'unknown'for an HTTP error, timeout or a body without a code; an adapter that throws counts asunknown. - On success the original flips to
refundedonly once its completed and reserved refunds (priorCreditsInCents) reach its amount; a partial refund leaves itcompleted. This partial-flip fix goes beyond the written TKN-425 decisions (plan assumption A9) and was flagged to the owner. - Member email
refundIssuedForTransaction(skipped onnotifyMember: false), then the credit document — best-effort, see below. - Audit
payment.refund_issued(capability: 'automatic'); observabilitypayment.refund_completed/payment.refund_failed. A refused or unknown outcome falls through to the manual task.
Card credit document (issueCreditDocument). Cardcom’s createRefundDocument against the refund’s own deal id (NewTranzactionId), product line החזר תשלום – <plan name> (החזר תשלום without a plan), Name cut to 50 characters, the member’s email (dropped with IsSendByEmail: false when over 50 characters), ExternalId = tkn-ccredit-<refund row id>. The outcome lands on the refund row: document_provider, document_type, document_number, document_url, document_issued_at when filed; document_error when not (no refund deal id, provider refusal, request failure), which puts it in the same owed-documents queue (payment_txns_document_error_idx) as a failed cash receipt. Events payment.credit_document_issued / payment.credit_document_failed.
Manual capability (Cardcom, iCredit, Meshulam, Morning, Tranzila)
openManualRefundTask / completeManualRefundTask (payment.service.ts). Two phases:
-
Open, under the same sale lock and cap as the automatic path: refuses if
refundTaskIdis already set (400Refund already in progress for this transaction) or the requested amount exceeds the cap (409refund_amount_exceeds_cap, nothing opened). Creates atasksrow withtype='manual_refund', priority='high'and a 3-day due date, and writesoriginal.metadata.manualRefund = { taskId, requestedAmountInCents, reason, origin, openedAt }. The original goes tostatus='refund_pending', refundTaskId=task.id.origin(manualRefundOriginValues) is why the task exists:refused— the provider refused the automatic refund;unknown— the automatic refund timed out or its outcome is unknown; the task description starts “Check the provider’s portal first — this refund may already have gone through.” and the preview carriescheckPortalFirst: true;non_cardcom— the charge’s provider is the active one but none of the automatic gates holds (a manual-capability provider);no_adapter— the charge was taken by another provider than the active one (or its provider has no registered adapter), so it can only be refunded there;review— opened byflagTransactionForManualReview; requests whatever is still refundable.
Also sends
CancellationNotificationsService.refundProcessing(txnId, { amountInCents })to the member — best-effort, never blocks the task. -
Preview:
GET /organizations/:orgId/payments/refund-tasks/:taskId→RefundTaskPreview(requested amount, prior refunds, cap, origin,checkPortalFirst). The web dialog (refund-task-complete-dialog.tsx) uses it for the default amount and the cap hint. -
Close:
POST /organizations/:orgId/payments/refund-tasks/:taskId/completebody{ externalReference, amountInCents }(document number 1–64 characters; the amount actually refunded). Checks in order, each before any write:- a task whose linked row is a cash-notice reservation (
metadata.source = 'cash_notice_settlement') → 409refund_task_use_cash_settlement{ transactionId, membershipId }(on the preview too): it settles through Confirm cash returned instead (Cash credit notes); - no amount → 400
refund_task_amount_required; - amount above the cap → 409
refund_amount_exceeds_cap.
Then, in one transaction: task →
completedwithcompletedById; acompletedrefund row is inserted with the original’s provider and method,refundExternalReference= the document number,metadata: { source: 'manual_refund_task', refundedTransactionId, taskId }. The original becomesrefundedonce refunds reach 100% (keepsrefundTaskId); a partial close leaves itcompletedwithrefundTaskId = nullandmanualRefund.closedAt, so another task can be opened for the rest. Auditstage: 'settled'withpartial. A replay of a completed task writes nothing and returns the earlier result. - a task whose linked row is a cash-notice reservation (
-
Notification:
refundIssuedForTransactionto the member, once.
Partial refunds
request.amountInCents is optional; the default is the full charge.
- Cash and other desk-recorded money — capped under a row lock and idempotent; see Cash credit notes.
- Card, automatic — capped under the sale lock, with the pending refund row written before the provider is called; the original flips to
refundedonly once refunds reach its amount, so a second partial refund stays possible. - Card, manual task — one open task per transaction; each close records the refunded amount as its own refund row, and the original flips to
refundedonly at 100%.
Chargebacks
Not modelled explicitly. Inbound refund.completed webhooks (webhook-processing.service.ts:521) flip the linked txn to refunded regardless of whether the gym initiated it — which is the closest we have. A chargeback would mark the txn refunded but leave the subscription active; the renewal cron will then attempt to re-charge and fail.
Cash credit notes (TKN-425)
A desk-recorded sale (provider = 'manual': cash, bank transfer, cheque, Bit) has no gateway deal to reverse. It is reversed by a credit document filed against its original tax document. CashCreditNoteService (services/cash-credit-note.service.ts) is the one path every such reversal takes:
| Caller | metadata.source | Refund row idempotency_key | cashReturned |
|---|---|---|---|
Credit note — POST /organizations/:orgId/payments/:txnId/credit-note | credit_note | cash-credit:<orgId>:<client key> | staff choice |
Staff ends a cash plan with a credit (ManualPaymentService.cancelCashPlan) | cash_plan_cancellation | cash-cancel:<subscriptionId>:<saleId> | staff choice, default true |
Confirm cash returned — POST /organizations/:orgId/payments/:refundTxnId/settle-cash-refund | cash_notice_settlement | the reservation’s own cash-notice:<subscriptionId>:<saleId> | always true |
POST …/payments/:txnId/refund on a manual transaction (ManualPaymentService.refund) | manual_payment_refund | manual-refund:<orgId>:<client key> | always true |
The standalone refund no longer refuses a plan sale (cash_prepayment_cancel_to_refund is gone): it credits the money but does not end access — cancelling the plan is still how access ends.
What can be credited
creditableOriginal (apps/api/src/payments/credit-document-kind.ts), checked before any write:
- Provider
manual, not itself a refund row, statuscompletedorrefunded. Otherwise 409credit_note_not_available.refundedis allowed so the remainder of a legacy partial refund (which flipped the whole sale) stays creditable; a sale that is really fully credited is then refused by the cap. - A filed document:
document_numberpresent, nodocument_error,metadata.cashDocumentSkippednot set. Otherwise 409credit_note_no_document— a credit with nothing to reverse states something untrue, so the sale’s own document is fixed first.
The credit type follows the original
creditKindFor reads the original’s stored document_provider and document_type (Cardcom answers with the enum name, its reports with the number; both are accepted):
| Original document | Credit kind | Cardcom files | Morning files |
|---|---|---|---|
Cardcom TaxInvoiceAndReceipt / 1 | tax_invoice_receipt_credit | TaxInvoiceAndReceiptRefund (type 2) | — |
Cardcom Receipt / 3 | receipt_credit | ReceiptRefund (type 4) | — |
Morning 320 / 305 | tax_invoice_receipt_credit | — | 330 |
Morning 400 | receipt_credit | — | 330 |
Anything else (null, a credit document, a type Taikan never issues) → 409 credit_note_kind_unknown; a tax document type is never guessed. Morning maps both kinds to its existing 330 credit and its payload is otherwise unchanged (the 330 + negative 400 pairing is TKN-434). Until this change every cash credit was filed as ReceiptRefund (type 4) whatever the original, which left a tax invoice’s VAT standing; the billing runbook lists those rows.
Amount, cap and lock
Everything below runs under SELECT … FOR UPDATE on the original transaction, so two credits against one sale serialize:
-
Cap = original amount − Σ amount of refund rows whose
metadata.refundedTransactionIdis the original and whose status iscompletedorrefund_pending(priorCreditsInCents). A cash-notice reservation counts, so it can never be credited a second time by hand. -
Default =
computeCreditable(the sale’s unused value at the subscription’scancelled_at, else now) − prior credits, clamped to[0, cap]:- recurring cash sale — the unused part of its immutable
cashPeriods, elapsed freezes rewound rather than charged; - settled unpaid term (a coverage row, no per-period prices) — the unused part of its coverage window;
- one-time pack — the unused share of its credits; one that never started (
scheduled, or no period) is wholly unused; - unlimited one-time sale — the unused share of its period by time; not started, or no end, is wholly unused;
- money recorded against no plan sale — the full amount.
Evidence that does not add up is refused (409
cash_prepayment_recovery_required), never guessed. - recurring cash sale — the unused part of its immutable
-
Staff may send any whole amount from 1 to the cap; outside it → 409
credit_note_amount_exceeds_cap{ capInCents, defaultInCents }. -
The original flips to
refundedonly when its credits reach exactly its amount (flipWhenFullyCredited); a partial credit leaves itcompleted.
GET /organizations/:orgId/payments/:txnId/credit-note/preview answers the same questions without writing: { currency, originalAmountInCents, priorCreditsInCents, capInCents, defaultAmountInCents, documentKind, originalDocumentNumber, originalDocumentProvider }, or the 409s above.
Idempotency
- Taikan. The refund row’s
idempotency_key(unique,payment_txns_idempotency_key_uniq). The same key with the same payload (metadata.creditRequest: original, amount, reason,cashReturned, source) returns the existing row withreplayed: trueand writes, audits and files nothing again; the same key with a different payload → 409credit_note_idempotency_conflict. The client’s key is kept onmetadata.creditNoteKey. The web dialogs mint one key per open. - Document claim. After commit the document is claimed once by a conditional update setting
metadata.cashDocumentAttempted; a replay finds the claim taken and reports the row as it stands. - Cardcom. Every document carries
Document.ExternalId—tkn-credit-<refund row id>for a cash credit,tkn-sale-<transaction id>for a desk sale,tkn-ccredit-<refund row id>for a card credit. Cardcom returns the same document for a repeated id within the company, so a timeout retry cannot file a second one.
Provider of record
The credit is filed with the credentials of the original’s document_provider (getCredentialsForProvider), never the org’s current provider — the same rule as Refunding across a provider switch. No credentials on file for it → 409 credit_note_provider_credentials_missing { provider } before anything is written. If they disappear between that check and issuance, the committed row carries that code in document_error.
What a credit writes
- Refund row, inserted
completed(a reservation is updated instead):provider = 'manual',type = 'refund', the original’smethod,membership_id,subscription_id,plan_idand currency,paid_at=completed_at= now,recorded_by,note= the staff reason,document_error = 'cash_document_pending'until the document lands, and the metadata keys in data-model.md. - Document, after commit, through
ManualDocumentService.issue: the credit kind above, positive amount, the original’s payment method (CardcomCashfor cash), line item = the staff reason (החזר תשלוםwhen there is none, and always for a cash-notice settlement), the member’s name and email (sendByEmailwhen an email exists — Cardcom omits an email over 50 characters),Comments=זיכוי למסמך מס' <original number>on Cardcom, anddocumentDate= the org-local day ofpaid_at. Cardcom receivesDocumentDate(MM/dd/yyyy,toCardcomDocumentDate) only when that day differs from today in Asia/Jerusalem — the format is unconfirmed and has a runbook live check. Success writesdocument_provider,document_type,document_number,document_id,document_url,document_issued_aton the refund row and clearsdocument_error; failure leavesdocument_errorset — the owed-documents queue. A document failure never undoes the money. - Audit
payment.credit_note_issued(target: the original;after: amount, currency,cashReturned, source, document number/error;metadata: refund row, membership, subscription, reason). Observabilitypayment.refund_completedwithcapability: 'manual',source,cashReturned.
Response, for both the credit-note and settle-cash-refund routes: { data: { refundTransaction, originalStatus: 'completed' | 'refunded', documentStatus: 'issued' | 'failed', document | null, replayed } }.
Cash that never arrived
cashReturned: false is the same reversal, with the same document type, for an invoice whose money was never received. The refund row is tagged metadata.cashReturned = false and metadata.reason = 'invoice_cancelled' (the staff text stays in note). Revenue drops exactly as for any completed refund. No cash-drawer or cash-out report exists today; any future one must exclude refund rows with metadata.cashReturned = false.
Ending a cash plan with a credit
cancelCashPlan takes refund: { transactionId, amountInCents, cashReturned } | null (staff-cancel body; cashReturned defaults to true). The amount is staff-editable and validated against the summed cap of the plan’s sales (409 credit_note_amount_exceeds_cap), then spread newest sale first — up to each sale’s unused value, then up to each sale’s cap (distributeCredit). Each share is recorded inside the cancellation’s own transaction (subscription FOR UPDATE) and documented after commit; a repeated cancel of an already-cancelled row replays without crediting again. Cancelling without a credit still requires a reason. previewCashCancellation returns creditableInCents (the suggested credit) and capInCents; it no longer refuses a scheduled pack or an unlimited one-time sale. Audit payment.cash_plan_cancelled. The staff dialog is described in Cash collection rail.
Settling a cash-notice reservation
When a member’s notice on a cash plan matures, settleCashCancellation reserves each sale’s unused amount as a refund_pending refund row (cash-notice:<subscriptionId>:<saleId>) with a manual_refund task due three org calendar days later. A non-metered one-time sale gets a general review task and no reservation. A cron cannot assert that cash changed hands, so nothing is settled automatically.
Staff settle it with Confirm cash returned (the reservation row, on the member’s payments tab and the org ledger), which posts settle-cash-refund { idempotencyKey }. Under locks on the original and the reservation it re-checks the original is creditable, completes the reservation at its reserved amount (paid_at, completed_at, recorded_by), completes its task, flips the original when fully credited, files the credit document and sends the member refundCompleted (reference: the credit document number, else the refund row id). A second confirmation replays. Anything that is not a cash_notice_settlement refund row → 409 credit_note_not_available. Closing the task by hand is refused (see Manual capability). Reservations written before this change settle the same way; nothing is auto-closed.
Staff UI
- A desk-recorded sale (
isCashCreditable: providermanual, not a refund,completedorrefunded) shows Credit note instead of the refund button, for staff withpayments/manage(member-payment-credit-note-{id}on the member’s payments tab,transaction-credit-note-{id}in the org ledger). The dialog (components/payments/credit-note-dialog.tsx) loads the preview, defaults the amount to the suggestion and caps it, requires a reason, offers a Cash returned switch (on by default; off explains that the invoice is cancelled with no cash out), and states the document type it will file. A refused preview shows the coded reason. - Rows show their document number, or a Document failed pill when
document_erroris set (member-payment-document-{id}/member-payment-document-failed-{id},transaction-document-{id}/transaction-document-failed-{id}). - A pending cash-notice reservation shows Confirm cash returned (
member-payment-confirm-cash-returned-{id},transaction-confirm-cash-returned-{id}), openingconfirm-cash-returned-dialog.tsxwith the amount and the document type.
Pay-later terms (TKN-426)
A term granted unpaid (enroll body unpaidGrace) is specified in Unpaid enrollment: the two dates it carries (payment_last_day, the last org-calendar day to pay, and payment_due_at, the org-local midnight after it), how they are counted, the collection task’s date, cash “received later”, and the desk’s settle and write-off actions. Once its due date passes it enters debt and follows Unpaid payment. Settlement is a desk-recorded sale like any other, so its receipt is creditable through Cash credit notes.
Unpaid payment: grace, then cancellation for non-payment
The rule is ADR-0021. A missed payment puts the plan in debt; the member keeps booking for the gym’s grace days (N); if the money has not arrived by day N the plan is cancelled for non-payment, the debt stays open and the app stays usable. Shipped without a feature flag.
States
| State | subscriptions row | memberships.payment_status |
|---|---|---|
| In debt (days 0..N) | status='debt', debt_amount_in_cents > 0, debt_since = day 0, debt_reason ∈ charge_failed / card_unusable / cash_lapsed / payment_due, next_charge_date = the next retry or null, current_period_* = the period the debt pays for | debt |
| Cancelled for non-payment | status='cancelled', cancellation_reason='non_payment', cancelled_at = the day-N pass, debt columns unchanged, next_charge_date=null; retention_outcome='lost_payment' only when a retention case is open | debt (the debt is still owed) |
| Restored | status='active', debt columns zeroed, debt_reason / debt_notice_stage null, failed_charge_attempts=0, cancellation fields cleared, next_charge_date = current_period_end on a card row with a card | current unless another plan still owes |
past_due is no longer written by any billing path; the enum value and its readers stay as legacy. The pure rules live in apps/api/src/payments/billing-debt.ts (debtCancelsAt, debtPayBy, debtRetryInstants, nextDebtRetry, resolveBillingDebt, hasOpenDebtAfterCancellation, earliestBillingDebt) and the row patches in apps/api/src/payments/services/debt-entry.ts (enterDebtPatch, retryFailedPatch, debtClearedPatch, debtClearedFields). The org’s policy is { graceDays: organizations.billing_grace_days, timezone } (loadDebtPolicy, apps/api/src/organizations/debt-policy.ts); N is 0–30, default 7, set in organizations › Billing grace setting.
Day 0
| Source | Writer | debt_reason | Amount | Period |
|---|---|---|---|---|
| Card renewal declined | legacy sweep handleChargeFailure, protected billingSettlement, webhook handlePaymentFailed | charge_failed | the agreed price of the missed period (negotiated, intro, bounded discount; a presale’s committed price) | the missed period, [min(next_charge_date ?? current_period_end, now), +interval]; credits refilled, new quota cycle, the intro cycle it is priced at consumed |
| Card expired or missing at charge time | markCardUnusable (protected card_expired_or_expiry_missing block, legacy card_missing, legacy pre-check on a known past expiry via cardUsableAt); emits BILLING_CARD_UNUSABLE after the debt is committed | card_unusable | same | same |
| Presale first charge declined, or card unusable | same writers | charge_failed / card_unusable | the agreed presale price | the presale starts that day (starts_on cleared, quota anchored at day 0); see Presales |
| Final cancellation bill declined | CancellationTailSettlementService | charge_failed | the tail amount (added to an existing debt, keeping its day 0) | kept; current_period_end extended to the effective date; no retries |
| Cash coverage ran out with no renewal recorded | lifecycle job | cash_lapsed | the next period at the agreed price | [cash_prepaid_through, +interval]; day 0 = cash_prepaid_through |
| Pay-later term passed its due date | lifecycle job | payment_due | unpaid_amount_in_cents | kept (the granted term); day 0 = payment_due_at; the unpaid columns stay |
Debt is entered only while debt_since IS NULL (guarded write), and every entry emits SUBSCRIPTION_DEBT_OPENED with reason, payBy and cancelsAt. A member-initiated renewSubscription decline writes nothing to the row (a member trying to buy and failing owes nothing). Renewal claims the period’s idempotency key before the provider is called, exactly as the sweep and early renewal do (claimChargeIntent; a second tap answers 409 renewal_already_in_progress), and runs only on a CHARGE_VERIFIED_PROVIDERS provider.
Retries
Card retries (charge_failed, card_unusable) run at org-local midnights: with N ≥ 2 on day ⌊N/2⌋ and day N−1, de-duplicated (N=7 → days 3 and 6; N=2 → day 1); with N=1 once, halfway between day 0 and the cancellation instant; with N=0 none. A retry charges debt_amount_in_cents, never a recomputed price. A declined retry only bumps failed_charge_attempts and moves next_charge_date to the next retry instant (null after the last); it never adds to the debt. The legacy sweep (02:00 UTC) and the protected catch-up (every minute) both select debt rows whose next_charge_date is due. A retry that finds the card still unusable advances the retry the same way.
Day N
The lifecycle job cancels a debt row once debtCancelsAt(debt_since) (org-local midnight of day N) has passed, unless a retry is still due (next_charge_date <= now) or a charge is pending on it (pending transaction on the subscription, or a pending protected-billing transaction on the membership). SubscriptionsService.endSubscription(reason: 'non_payment') writes the cancellation, the observability event (source: 'lifecycle'), the churn event with the reason, the SUBSCRIPTION_CANCELLED audit row (mode: 'lifecycle', reason, debtAmountInCents), sweeps future bookings (payment_issue), stops the provider standing order on card rows, then sends the cancellation notice. The debt stays open.
Paying the debt
Only the full debt is accepted. Payment clears the debt and restores the plan for the period the debt covered; the period is never re-anchored, and the next charge stays on the normal date (current_period_end).
| Rail | Path |
|---|---|
| Card retry succeeds | billingSettlement / handleChargeSuccess / webhook handleSubscriptionRenewed on a row with debt_since → debtClearedPatch, SUBSCRIPTION_RENEWED |
| Card, staff | POST /organizations/:orgId/subscriptions/:id/clear-debt → DebtService.clearDebt (accepts debt or cancelled for non-payment); protected path: BillingCollectionService |
| Card, member | POST /organizations/:orgId/subscriptions/my/:id/pay-debt (no body) → DebtService.clearDebt on the caller’s own membership; returns the refreshed /my row. 400 when nothing is owed; a cash-rail debt gets 409 cash_prepayment_incompatible_state (memberActions never offers pay_debt on the cash rail or for externally billed plans) |
Cash desk (cash_lapsed) | record a cash renewal: the first quoted period is the debt period (coverageStart = current_period_start, priced at debt_amount_in_cents), later periods at today’s quote; period unchanged; works while in debt and after cancellation |
Pay-later (payment_due) | settle, record() replay or write-off → closeUnpaidTerm (unpaid-term-close.ts): a debt row is restored; a cancelled-for-non-payment row is restored while its granted period runs, otherwise stays cancelled with the debt cleared |
refreshMembershipPaymentStatus recomputes the membership: debt while any other row is debt or cancelled with an open debt, else current.
A debt outlives a notice. A member in debt may still give notice (cancellation is never conditioned on payment), and the renewal sweep and lifecycle both leave a cancel_at_period_end row alone, so no retry and no day-N cancellation run during the notice window. The row ends on the member’s effective date with its debt columns untouched, and hasOpenDebtAfterCancellation treats any cancelled row with debt_since set and debt_amount_in_cents > 0 as still owing: it stays in getDebtReport, keeps the pay_debt action, blocks the member’s bookings and keeps the membership debt. Paying it settles the money without restoring the plan (debtClearedPatch restores only a non-payment cancellation). The member and guardian pay-debt routes honour the same CHARGE_VERIFIED_PROVIDERS allowlist as the desk route on the provider-direct path.
Failures on our side start no clock
Cardcom down (provider_circuit_open), the terminal busy or blocked, an unknown outcome (timeout), a card saved on another terminal (payment_method_account_mismatch / _unverified) and no provider configured write nothing to the row: it stays as it was until the charge resolves, and the lifecycle job never ends a renewing card plan (next_charge_date set or a card on file).
Where it is enforced and shown
- Booking — a plan in debt still books, up to its cancellation instant; a plan cancelled for non-payment with debt refuses the member (
booking_blocked_debtwithbillingDebt), even beside a healthy plan. Staff bypass. See scheduling-bookings › Billing gate. GET /organizations/:orgId/subscriptions/myand the staff subscription list — every row carriesbillingDebt: { amountInCents, since, reason, payBy, cancelsAt, state: 'grace' | 'cancelled' } | nullandpaymentLastDay;memberActionscarriespay_debtfor a card-rail row in debt or cancelled for non-payment.- Member status — a plan in debt keeps the member
training(atDate= pay-by day,billingDebtattached); a non-course plan cancelled for non-payment with debt puts the member innon_payment, ranked before leaving and training. See memberships. - Clients — web and mobile show “You owe ₪X. Pay by {date} or your plan will be cancelled.” during grace and “Plan cancelled for non-payment. You owe ₪X.” after, with a pay button (when
pay_debtis offered) and a contact-the-gym link.
Member notices
BillingDebtNotificationsService (apps/api/src/notifications/billing-debt-notifications.service.ts), email and push under the member’s billing preference (push route /(tabs)/profile/payments). Each stage is claimed by a compare-and-set on subscriptions.debt_notice_stage before sending (null → opened → last_day → cancelled), so each is sent at most once per debt; clearing the debt resets the stage.
- Debt opened — on
SUBSCRIPTION_DEBT_OPENED(every reason exceptcard_unusable): emaildebtOpenedEmail, pushbillingDebtOpened(amount, pay-by day). A card debt gets a pay button; a cash or pay-later debt gets “you can pay at the gym”. - Card unusable — on
BILLING_CARD_UNUSABLE: emailcardUnusableEmail, pushbillingCardUnusablewith amount and pay-by day; counts as theopenedstage. - Last day — the lifecycle pass on the pay-by day (N ≥ 2 only):
debtLastDayEmail,billingDebtLastDay. - Cancelled — after the day-N cancellation:
debtCancelledEmail,billingDebtCancelled. - Card expiring —
runCardExpiryReminders, daily 08:00 UTC: cards a renewing card-rail plan still uses, 14, 7 and 1 org calendar days before the card stops working; RedisSET NXper card and day. EmailcardExpiringEmail, pushbillingCardExpiring. - A declined retry sends
paymentFailedEmailwith the next retry and the pay-by day.
Delivery is at most once; a claimed notice whose delivery failed is not retried. Operations: scheduled jobs › Subscription lifecycle.
Automations
SUBSCRIPTION_DEBT_OPENED fires on day 0 (every source), and MEMBERSHIP_CHURNED fires for a non-payment cancellation with its reason.
Webhook handling
PaymentWebhookController (apps/api/src/payments/controllers/payment-webhook.controller.ts). Two URL shapes:
| Form | Used by | Why |
|---|---|---|
POST /webhooks/payments/:provider/:orgId | Cardcom, iCredit, Meshulam, Tranzila | Provider accepts per-payment notify URL; we encode orgId in the path. |
POST /webhooks/payments/:provider?org=… | Morning | Provider supports only ONE statically-registered webhook per business; owner pastes the URL once. |
Signature verification
Per-provider, in validateWebhookSignature. See README — Providers for the table. The dispatch is PaymentWebhookController.handleWebhook.
The webhook URL is guessable (/webhooks/payments/<provider>/<orgId>, and the checkout return URL hands the member the subscription id), so the posted body is never trusted on its own. Three layers:
- Verification (controller). A provider that signs its webhooks (iCredit, Meshulam, External/Grow, Morning’s echoed secret) is checked in
validateWebhookSignature. A provider that signs nothing (Cardcom) is re-fetched from its own API inverifyWebhookOutOfBand, and the provider’s answer replaces the body as the event. Either success stampsmetadata.providerVerified = trueon the event. A verification failure that is transient (provider_unreachable,deal_not_finalized) is answered 503 whether or not enforcement is on, so the provider redelivers and nothing is lost. - Enforcement (controller,
WebhookEnforcementService). For a non-transient failure (bad signature, a page Cardcom cannot place) the per-org flagpayment-webhook-signature-enforcementdecides the HTTP answer: on → 400 andpayment.webhook_rejectedwithoutcome: failure; off → 200, the body still reaches the processor, andpayment.webhook_rejectedis emitted with the reason suffixed_unenforced. That suffix is the measurement for turning the flag on: it counts the webhooks that would have been rejected. - The processor refuses unverified events (
WebhookProcessingService.processWebhookEvent). Before the cross-org guard and before any handler, an event withoutproviderVerified: trueis logged, Sentry-captured (unverified payment webhook refused, fingerprinted per provider and org), emitted aspayment.webhook_ignoredwithreason: 'unverified', and dropped with a 200. So an unverified body can never activate a subscription, advance a period, settle a transaction, store a card token or record a failure, regardless of the flag. The flag only changes whether the provider is told 400 or 200.
Internal callers that re-fetch the truth themselves mark their events verified: verifyAndActivateReturn (payment.service.ts) and the e2e complete-payment seam for the test provider. Presale fulfillment (PresaleCheckoutService) also checks the flag on its own.
Cross-org abuse is still blocked separately: metadata.subscriptionId must belong to the org named in the URL (subscriptionBelongsToOrg), and course entitlements are checked the same way.
Idempotency
| Mechanism | Where |
|---|---|
txnByProvider.status === 'completed' short-circuit | webhook-processing.service.ts:121 |
| State-machine guard “refuse anything not pending→terminal” | platform-billing.service.ts:313 |
Refund task completed → alreadyCompleted: true no-op | payment.service.ts:599 |
Cash credit note: refund row idempotency_key replay; payload mismatch → 409 credit_note_idempotency_conflict | cash-credit-note.service.ts (recordCredit) |
Desk-recorded document: Cardcom ExternalId per row (tkn-sale- / tkn-credit- / tkn-ccredit-) | manual-payment.service.ts, cash-credit-note.service.ts, payment.service.ts |
Unpaid-term settlement key unpaid-settle:<sub>[:<client key>]; record() replays it before reading the subscription | manual-payment.service.ts (unpaidSettlementKey, record) |
Unique-by-provider-id index on platform_billing_transactions | libs/db/src/lib/schema/platform-billing.ts:120 |
Course entitlement upsert by (user_id, program_id) | libs/db/src/lib/schema/courses.ts:145 |
Course completion upsert by (entitlement_id, course_workout_id) | libs/db/src/lib/schema/courses.ts:204 |
The per-org payment_transactions table has no unique index on (org_id, provider_transaction_id). Concurrent webhook + verify-return for the same processId can both run the success path; the second one finds status='completed' and bails (webhook-processing.service.ts:121), but the period-advance update in the trailing transaction can still race. See FIT-133.
Partial-state contingencies
| Scenario | What happens | Recovery |
|---|---|---|
| Provider returns success but DB write throws after adapter call | Adapter call already happened (card debited). Pending row stays pending. Next webhook arrival OR next cron tick reconciles. | Cron verify-return route or manual replay of the webhook. |
| DB row inserts but provider call times out | We insert pending before the adapter call (recurring-charge.service.ts:122). If the provider actually succeeded but we never got the response, the next webhook resolves it; if it failed silently, the pending row stays open. | Reconciler (platform-billing has one; per-org payments lacks one — gap). |
| Webhook never arrives | verify-return POST from the success page replays the same processing logic. | Member or owner can hit /verify-return again. |
Multiple webhooks for the same processId | Short-circuit on completed status. | None needed. |
| Refund task closed twice | Service guard returns alreadyCompleted: true. | None. |
Refund task externalReference is wrong | No verification — we trust the gym owner. | Edit task description, no automated repair. |
| Credit committed, credit document not filed | The refund row is completed with document_error (cash_document_pending if the call never ran, the provider’s reason otherwise); the original’s status already reflects the credit. | Owed-documents queue (payment_txns_document_error_idx); file the credit by hand with the accountant. Never re-credit to get a document. |
| Cash-notice reservation closed through the manual-refund task | Refused, 409 refund_task_use_cash_settlement; nothing written. | Confirm cash returned on the reservation row. |
| Member closes the hosted form / the card is declined | No provider event exists. The subscription stays pending and the member is offered the decision (resume or roll back) on the return page. | Member resumes, rolls back, or the 24h sweep releases the row. payment.checkout_cancel_returned is the only trace either way. |
| Member resumes, but the plan sold out / retired / grew a signing requirement meanwhile | resumeCheckout delegates to purchase, so every gate re-runs — the member gets the same refusal a fresh purchase would, not a payment page for something they can’t have. | Buy a different plan; sign the form and retry. |
| Member rolls back a checkout that a late payment then completes | The row is cancelled + abandoned_checkout, which revivingAbandonedCheckout still recognises — the subscription is revived and becomes visible again. | None. This is why the rollback must not invent its own reason string. |
| Presale withdrawn while the opening-day sweep is promoting it | The withdrawal re-checks status = 'scheduled' under FOR UPDATE; if the sweep won, it 409s rather than closing a membership that just started. | Reload; the member now has a live membership and the cancellation flow. |
Side effects
| Action | Side effect |
|---|---|
| Charge succeeded | sendPaymentReceipt email (payment.service.ts:783), membership payment_status = current, subscription.remainingCredits refilled (a debt row instead has its debt cleared with the period unchanged), observability event payment.subscription_activated / payment.renewal_succeeded. |
| Charge failed (renewal) | First decline: sub and membership → debt, SUBSCRIPTION_DEBT_OPENED (debt-opened notice). Declined retry: paymentFailedEmail with the next retry and pay-by day. |
| Refund initiated (auto) | payment.refund_initiated event, new refund txn inserted (tied to subscription and plan), original txn → refunded once fully refunded, member email refundIssuedForTransaction, credit document persisted on the refund row (or document_error). |
| Refund task opened (manual) | payment.refund_task_opened event, tasks row created (priority=high), member email cancellationNotifications.refundProcessing. |
| Refund task closed | Completed refund row (amount + document number), original → refunded at 100% only, member email refundIssuedForTransaction, audit stage: 'settled', observability payment.refund_completed. |
Refund task overdue (RefundTaskSweepService, daily) | Task priority bumped high → urgent, payment.refund_task_overdue event. No email — the escalation is to staff via task priority, not to the member. |
| Cash credit note issued | Refund row completed; original → refunded when fully credited; credit document filed through the original’s provider and emailed to the member when an email is on file; audit payment.credit_note_issued; observability payment.refund_completed (capability: 'manual'). No separate refund email. |
Cash returned confirmed (settle-cash-refund) | Reservation refund_pending → completed, its manual_refund task completed, credit document filed, member email cancellationNotifications.refundCompleted. |
| Debt opened / last day / cancelled, card expiring / unusable | Member email + push under the billing preference — see Member notices. |
| Member gives cancellation notice | Member receives cancellationScheduled; an urgent SLA-timed cancellation_review task opens. No per-event staff email — the notice rolls into the org’s next daily digest. See Retention case. |
Presale withdrawn (scheduled → cancelled) | payment.subscription_cancelled with source: 'member_presale_withdraw', audit row (mode: 'presale_withdraw'), pending transactions cancelled, seat released. presaleWithdrawnForMember confirms release to the member; a retention case opens with a low-priority, no-deadline task and immediate presaleWithdrawnForStaff email to the case owner (falling back to the org’s active owner). Still no cancellation form and no refund — nothing was charged and no membership ever started. |
| Checkout rolled back by the member | payment.checkout_abandoned with reason: 'dismissed_by_member' plus intent/source, audit row (mode: 'member_pending_cancel'), checkoutCancel stamped on the abandoned transaction’s metadata. |
| Join-link checkout released unpaid | payment.checkout_abandoned carrying cause: 'declined' | 'abandoned', payment.join_membership_released, a CRM lead, and the matching recovery mail plus payment.recovery_email_sent / _failed. See Recovering an unpaid join-link checkout below. |
Audit-critical: refunds, cancellation request approvals, and manual-task closures all record actor userId (cancellationRequestedBy, resolvedByUserId, completedById). Desk money writes an AuditService.record row (category payment) for each step: payment.cash_recorded (cash sale), payment.credit_note_issued, payment.cash_plan_cancelled, payment.unpaid_term_settled, payment.unpaid_term_written_off, alongside the existing payment.refund_issued. Logs go through PaymentObservabilityService.emit(...) — the consumer is payment-monitoring.service.ts plus Sentry.
Recovering an unpaid join-link checkout
A registrant who reaches a payment page and does not pay ends one of two ways, and they are opposite events that used to be recorded as one.
What a Cardcom payment.failed actually is
Measured over 30 days of production (33 webhooks, two terminals): every
payment.failed Cardcom sent arrived 20–30 minutes after LowProfile/Create,
on Cardcom’s own ~10-minute sweep grid, with an empty TranzactionInfo and no
TranzactionId. Not one within the window a person could have submitted a
card. That is the payment page expiring with nobody on it — a walk-away,
not a refusal.
A refused card, on these terminals, produces no webhook at all: Cardcom
shows its own /EA/Error page (“אירעה שגיאה לא צפויה / החיוב נכשל”) and calls
neither WebHookUrl nor FailedRedirectUrl (observed 2026-09-03, page
aeb2e9b1-…). The terminals also run with CVV/ID checks removed (the Israeli
recurring-terminal requirement), so a wrong CVV or a wrong-but-future expiry is
approved by the issuer — declines cannot be manufactured from a good card.
So CardcomProvider.mapDealToEvent classifies a failure with no
TranzactionId as failureKind: 'checkout_expired', and
WebhookProcessingService.resolveExpiredCheckout:
- resolves the attempt by page id (
metadata.lowProfileIdagainst the pending row’sproviderTransactionId, whichPaymentServiceset to Cardcom’sLowProfileIdat creation), so an old page expiring late never closes a newer one the member is typing into; - only for a still-
pendingsubscription, org-scoped through the membership; - marks the attempt
cancelled(the same marker the supersede path uses), neverfailed; - emits
payment.webhook_ignoredwithreason: 'checkout_page_expired'and stops — noactivation_failed, no Sentry, subscription leftpendingfor the release sweep to decide on.
A failed attempt therefore means a refused card and nothing else. That is
what wasDeclined(), the CRM note, the stuck-pending alert and the recovery
template all read.
Recording a genuine decline
If Cardcom ever reports a refusal it will carry the deal it refused — a
TranzactionId and an acquirer ResponseCode — and takes the id-keyed path.
That path looks the deal id up first and then the page id
(lowProfileId): the pending row only learns the deal id on success, so
without the second lookup every real decline escalated as
transaction_not_on_file and opened a reconciliation task. The attempt is
then marked failed carrying the gateway’s own errorMessage and raw body,
and payment.activation_failed gains declineCode / errorMessage alongside
our own reason; a Payment declined: <kind> warning goes to Sentry,
fingerprinted on the cause.
Verification. Cardcom signs nothing, so every Cardcom webhook goes through
verifyWebhookOutOfBand.GetLpResult’s rootResponseCodeis the outcome of the page, not of the lookup, so an expired or refused page answers its own failure code (5119/5118 timeout, 2006 brand refusal) while still echoing ourLowProfileId, our terminal and ourReturnValue. Such a known page’s failure is processed as Cardcom reported it, under enforcement too. Only a page Cardcom cannot place — 5096 with an all-zeroLowProfileIdand terminal 0, captured on production 2026-09-28 — is refused asdeal_not_found. A body claiming success for a page Cardcom reports unfinished is answered503(deal_not_finalized) so Cardcom’s redelivery settles it. Before 2026-09-28 every known-page failure read asdeal_not_found: 40 webhooks since 2026-08-15, all failed or timed-out attempts, none a success. Under enforcement they would have been answered400, which Cardcom redelivers like any non-200 (7 retries over ~25h) while the attempt stayedpending.
Where the decline detail lives depends on which path saw it, and both
fields are read acquirer-first (TranzactionInfo before the envelope): on the
verified path a page whose envelope ResponseCode is 0 can still carry a
declined deal, so the root Description there is the literal "Success"
while the refusal sits in TranzactionInfo.ResponseCode / .Description.
Root-first would have put the word “Success” in the decline email. Pinned in
cardcom.provider.unit.spec.ts.
Three things downstream read a failed attempt, and all three misread an
expired page while it was recorded as one:
| Reader | Expired page (cancelled) | Refused card (failed) |
|---|---|---|
detectStuckPending | outcome: 'ignored', reason: 'checkout page expired — awaiting release sweep'. A genuinely stuck checkout (page still pending, no word from the provider) still emits failure. | outcome: 'ignored', reason: 'declined charge — member may retry'. |
| Join-link release sweep | cause: 'abandoned', reason: 'join link, unpaid > 30min'. | cause: 'declined', reason: 'join link, card declined'. |
| CRM lead note | ”never completed payment”. | Says the card was declined and tells the coach to offer another payment method. |
The recovery mail
One sender: the join-link release sweep, after the release, with the
template that matches the ending (wasDeclined() → decline template, else
abandon). One letter per checkout because the release fires once per
subscription under FOR UPDATE; no dedupe key needed.
There is deliberately no “at the moment of the decline” sender. On Cardcom
there is no such moment to hook: a refused card produces no webhook and no
redirect (the member is parked on Cardcom’s /EA/Error page), and the only
payment.failed Cardcom sends is the page expiry described above. A previous
version answered that expiry as a decline, immediately — which would have
told every member who closed the tab that their bank had refused them.
Two templates in templates/checkout-recovery.ts, not one with a flag: telling
someone who closed a tab that their card was declined is false, and asking
someone whose bank refused them whether they changed their mind reads as
though we did not notice they tried. Both state plainly that nothing was
charged — a released membership plus a half-filled payment page is exactly the
shape people read as “they took my money and gave me nothing”.
Join-link registrants only. CheckoutRecoveryService applies the release
sweep’s own bar before writing — member role, join_link source, and a
subscription history made entirely of unfinished checkouts
(isUncommittedRegistrant). A returning member whose plan change was declined
is never told their “registration” did not complete; the result is
skipped: 'not_join_registrant' and nothing is emitted.
The button goes back to a join link, because redeeming one reactivates the same
membership and opens a fresh checkout; an in-app subscription page would show a
released member nothing to pay for. Gated on the org first — an org with
joining switched off, or inactive, 404s every link it has. Then two sources,
best first: the link they arrived through, then any live join_links row of
the org’s (preferring the plan they chose). Never organizations.join_token:
the join page resolves join_links.token only, and an org created after the
join_links migration carries a legacy value there that no page answers to —
offering it put a 404 behind the button (FitKit PROD, 2026-09-03). No live
link anywhere means no button — the mail still goes, since it is the only
notice the member gets.
Sent at most once per checkout: the release that triggers it flips pending →
cancelled under FOR UPDATE, so a second sweep pass never reaches it. Every
send is best-effort and swallowed — the membership is already released and the
audit row already written, and a throw would abandon the rest of the batch.
Seeing it
- PostHog —
payment.checkout_abandoned(cause),payment.recovery_email_sent/_failed, andlead_created, which is new: the CRM demotion previously wrote a lead row and emitted nothing, so “the seat was freed” was measurable and “the person was kept” was not. - Sentry — declines are captured as a
warning, fingerprinted on[decline kind, decline code], so it is one issue per cause whose count is the signal, never one per member. A recovery mail that fails to send is captured as an exception: that is a lead lost silently, which is the failure this whole path exists to end. - The dead end, closed. A released registrant who signs in used to get “your membership is currently inactive / please contact your organization admin” and a sign-out button — for someone whose card was refused forty minutes earlier, and whose gym mostly never hears about it.
/users/menow returnsrejoinToken(andrejoinPlanId, the released checkout’s plan, so the button reopens that offer) on a membership that can be redeemed back, andRoleRouterturns that screen into an action. The API owns eligibility:cancelledandjoin_linkonly, only when the person never committed to a subscription (isUncommittedRegistrant— staff removal writes the samecancelled, and a member who paid for a year and was removed must not be handed a “finish signing up” button), and only when the org still has joining enabled and a live link. A suspended member never gets one —acceptJoinLinkrefuses a suspended membership, so the button would fail.resolveRejoinTokenis shared with the email’s button so the two offers cannot drift apart. - One person, one profile — the API keys events on the Taikan user id. The web SDK now identifies with the same id (read from
/users/me,apps/web/src/providers.tsx), so no merge is needed. Theuser.createdtracking.alias(user.id, clerkId)stays for the mobile app, which still identifies with the Clerk id: the alias folds that profile in when the client identify comes after it (observed working, 7 min later), but loses the race at web sign-up where the browser’s$identifylands first and PostHog then refuses to merge an identified person — which is why the web side switched keys rather than relying on it.
Permissions
| Action | Route | Required role |
|---|---|---|
| Configure provider | POST /organizations/:orgId/payment-config | owner or admin (payment-provider-config.controller.ts:29) |
| Refund a transaction | POST /organizations/:orgId/payments/:txnId/refund | owner or admin, payments/manage (payment.controller.ts); a manual transaction also needs idempotencyKey |
| Preview / issue a cash credit note | GET …/payments/:txnId/credit-note/preview, POST …/payments/:txnId/credit-note | owner or admin, payments/manage (payment.controller.ts, rechecked in CashCreditNoteService) |
| Confirm cash returned (settle a cash-notice reservation) | POST …/payments/:refundTxnId/settle-cash-refund | owner or admin, payments/manage |
| Close manual_refund task | POST /organizations/:orgId/payments/refund-tasks/:taskId/complete | owner or admin |
| Settle / write off an unpaid term | POST /organizations/:orgId/subscriptions/:subscriptionId/settle-unpaid, …/write-off-unpaid | owner or admin, payments/manage (manual-payment.controller.ts) |
| List org transactions | GET /organizations/:orgId/payments | owner or admin |
| List my transactions | GET /organizations/:orgId/payments/my | any active member |
| Acknowledge / contact / set outcome / withdraw a retention case | POST /organizations/:orgId/subscriptions/:id/retention/*, …/withdraw-notice | owner or admin, subscriptions/manage — see subscriptions-plans/behavior.md |
| Cancel subscription (admin) | POST /organizations/:orgId/subscriptions/:id/cancel | owner or admin (subscriptions.service.ts:302) |
| Register card for a member | POST /organizations/:orgId/members/:id/register-card | owner or admin (card-registration.controller.ts:44) |
| Change platform tier | PATCH /organizations/:orgId/tier | owner only (platform-tiers.controller.ts:64) |
| List member payment methods | GET /organizations/:orgId/members/:membershipId/payment-methods | owner or admin, payments/view |
| Manual charge (desk charge) | POST /organizations/:orgId/members/:membershipId/charge | owner or admin, payments/manage (manual-charge.controller.ts:13) |
| Clear debt | POST /organizations/:orgId/subscriptions/:subscriptionId/clear-debt | owner or admin, payments/manage (manual-charge.controller.ts:31) |
| Inspect / reconcile a protected attempt, authorize one retry | GET …/payments/billing/transactions/:transactionId, POST …/reconcile, POST …/authorize-retry | owner or admin, payments/manage (billing-resolution.controller.ts) |
| Confirm no debit and release a protected hold | POST …/payments/billing/transactions/:transactionId/confirm-no-debit | owner only, rechecked under the money lock (billing-resolution.service.ts) |
| Presale readiness report and repairs | GET/POST /admin/organizations/:orgId/billing-readiness[…] | Taikan platform admin only (admin-billing-readiness.controller.ts) |
Plan CRUD additionally requires the membership_plans feature on the org’s tier (plans.controller.ts:69).
Manual charges (staff) — FIT-254 §3.3
POST /organizations/:orgId/members/:membershipId/charge (manual-charge.controller.ts:13 → ManualChargeService.charge, manual-charge.service.ts:86). Body { amountInCents: int ≥ 100, description: string }. Currency is always org currency.
Guard stack, in order (manual-charge.service.ts:90-119):
- Permission
payments/manage(owner/admin). - Membership exists in org (cross-org → 404) and
status === 'active'— otherwise 409MEMBERSHIP_NOT_ACTIVE. - A provider config exists for the org — otherwise 400
"No payment provider configured for this organization"(BadRequestException, not a structuredPaymentErrorCodesentry — the existing idiomcard-registration.controller.tsalso uses). Note: the FIT-254 spec’s §3.3 table says this should be 409no_payment_provider; the shipped code throws plain 400. Documented here as the actual behavior — see the ADR for the discrepancy. - Provider is in
CHARGE_VERIFIED_PROVIDERS(manual-charge.service.ts:34:['cardcom', 'meshulam', 'test']) — otherwise 409PROVIDER_CHARGE_UNVERIFIED. - Active payment method exists on the membership — otherwise 409
NO_ACTIVE_PAYMENT_METHOD.
Mechanics — pending-row-first, identical discipline to recurring-charge.service.ts: insert a payment_transactions row (type='charge', status='pending', metadata: { source: 'manual_charge', chargedByUserId, description }) before calling adapter.createCharge; update the same row to completed/failed afterward, never delete it — a crash between the adapter call and the update leaves a pending row for reconciliation, never a charged-card-with-no-record. An adapter throw (network/timeout) is caught and treated as a failed charge, same row.
No idempotency key — each request is a distinct charge attempt; a double-click or retry-after-timeout produces two separate transactions. The confirm dialog (client-side) is the only guard (documented gap, matches PD-A3’s “confirm step” design).
Observability: payment.manual_charge — amount, orgId, membershipId, actor, provider, outcome. The description free-text field is deliberately never sent to PostHog (PII risk) — it lives only in the DB row and the receipt email.
Does NOT emit SUBSCRIPTION_RENEWED — that event drives membership_renewed automations; a desk charge is not a renewal.
Receipt: on success, PaymentService.sendPaymentReceipt fires with the description as an extra line item (payment-receipt.ts gained an optional description field, rendered as Description: <text> when present).
Refund: unchanged — the existing capability-aware refund path (see Refund flows) applies to manual-charge transactions with no special-casing.
Member visibility: appears automatically in GET payments/my and the profile payment history as a charge-type transaction.
Debt collection — FIT-254 §3.4
POST /organizations/:orgId/subscriptions/:subscriptionId/clear-debt (manual-charge.controller.ts → ManualChargeService.clearDebt). Same guard stack as manual charge (permission → provider-config-exists-as-400 → allowlist), then wraps DebtService.clearDebt: charges the full debtAmountInCents on the active card and, on success, restores the sub (debtClearedPatch) — a row in debt or one cancelled for non-payment. Full amount only — no partial collection in v1. On failure the sub stays debt, nothing else mutates. The wrapper’s response now also surfaces nextChargeDate (see A6 below) so admin UI can show the member is back on a real billing cadence.
The pre-existing test-secret route (used by e2e seeding) calls DebtService.clearDebt directly and is unaffected by this guard stack.
A6 rewrite (FIT-254 review Wave A, MAJOR): DebtService.clearDebt had three defects, all fixed:
- Charge-before-record → pending-row-first. The
payment_transactionsrow is now inserted BEFOREadapter.createCharge(mirroringmanual-charge.service.ts’s discipline) and updated after, regardless of outcome — a charge that succeeds at the provider but fails to persist locally is still on record. - Non-idempotent under concurrency → atomic post-charge claim. Holding a DB row lock across the provider round-trip is unacceptable latency-wise, so instead: a cheap, non-locking re-read guard runs immediately before the charge (catches the common “already cleared” case without a wasted charge), and the REAL guard is an atomic claim AFTER the charge succeeds —
UPDATE subscriptions SET <debtClearedPatch> WHERE id=... AND status IN ('debt','cancelled') AND debt_amount_in_cents = <the amount just charged> RETURNING. If two concurrentclearDebtcalls both pass the pre-charge checks and both charge the provider, only the first atomic claim wins; the second’s charge is a genuine double-collection. That case is not thrown as an error to the caller (the debt genuinely is cleared, by the other request) — it’s flagged viaPaymentService.flagTransactionForManualReview(a thin wrapper around the existingmanual_refundtask-open helper), which flips that transaction torefund_pendingand opens amanual_refundtask so an owner reconciles the double charge. - Never bills again →
nextChargeDaterestored. On success,nextChargeDateis set tocurrentPeriodEnd(the normal billing date; null for non-recurring plans or with notice given) andfailedChargeAttemptsreset to 0 — previouslynextChargeDatestayedNULLforever after a debt clear, so the subscription silently never re-entered the recurring-charge cron until another manual intervention.
Clearing one plan, not the member. The success claim also clears debt_reason and debt_notice_stage and, for a non-payment cancellation, the cancellation fields. A payment_due debt is closed through closeUnpaidTerm. The membership’s payment_status becomes current only when no other non-deleted plan still owes (refreshMembershipPaymentStatus) — the same rule as protected debt collection. Members pay their own card debt through POST …/subscriptions/my/:id/pay-debt (Paying the debt). getDebtReport lists non-payment cancellations that still owe alongside debt rows.
C6 fix (FIT-254 review Wave C): DebtService.clearDebt’s “no active payment method” rejection now carries a structured code — ConflictException({ code: 'no_active_payment_method', message }), same as ManualChargeService.charge’s identical check — instead of a plain, uncoded BadRequestException. Previously the web collect-debt dialog had no code to map for this specific failure no matter how its error-mapping was written; it now renders localized copy like every other known code.
Observability: payment.debt_cleared (amount, orgId, membershipId, subscriptionId, actor).
B1/B2 reuse (FIT-254 review Wave B): PlanChangeService.applyImmediateCharge now calls the same PaymentService.flagTransactionForManualReview for its own analogous race — a charge that succeeded but whose plan-change swap either lost a concurrency guard or threw outright. See subscriptions-plans/behavior.md’s plan-change section for the full mechanics (advisory lock, re-verify, conditional swap).
B10 — new general-type reconciliation tasks (FIT-254 review Wave B): distinct from the manual_refund task machinery above (which gates a transaction’s refund_pending → refunded transition), a post-commit booking-entitlement sweep failure (after an admin cancel, a cron period-end flip, or a webhook plan-change activation) opens a plain type='general', priority='high' task naming the affected subscription(s) — there’s no linked transaction to gate, just a “go look at this member’s bookings manually” pointer. See subscriptions-plans/behavior.md’s C4/B10 note.
Billing resilience — TKN-353
Protected member collection: a charge is recorded as a durable claim before the provider is called, an unknown outcome is never read as a decline, and settlement is one transaction. Operational procedures (reconciliation, release, rollout, Kinetics) live in the billing resilience runbook; this section is the behavior contract.
Routing
- Protected collection applies to every organization whose active provider is Cardcom, the only provider the collector can reconcile by external attempt id (
RecurringChargeService.isProtectedOrganization). There is no per-organization switch. Organizations on other providers keep provider-direct collection for renewals, desk charges and debt. - A row is collected on the protected path when its organization is on Cardcom, or when the obligation is already persisted on the protocol: a v1 attempt for this first-charge lineage or renewal key (
BillingAttemptService.hasProtectedObligation). A provider switch never moves persisted money to the provider-direct path. - A pending protected claim blocks every other collection for that member (
hasUnresolved), whichever path would run it. - Webhooks: a Cardcom webhook is always enforced (
WebhookEnforcementService), since Cardcom’s own API is the verifier; the processor refuses any unverified event regardless (see Signature verification).
The attempt protocol
- Claim. Under organization → membership → subscription locks (
lockBillingAccount), the collector inserts apendingledger row carryingmetadata.billingAttempt(version 1): obligation, amount, currency, original terminal and a compact external attempt id (≤ 25 characters, sent to Cardcom asExternalUniqTranId). A request the gateway could never have received (no token, no expiry, unusable amount/currency) fails before any claim exists. - Charge. The provider call runs outside the lock. Only one worker may be inside a provider call per terminal: the terminal state is
open,busy(someone is charging — come back later, not an incident) orblocked(unresolved money needs a human). - Outcome. An issuer decline, a technical rejection and an unknown result (timeout, lost response) are separate outcomes. Unknown stays
pendingand holds the member; it is never counted as a decline and never retried blindly. - Apply. Success settles ledger, subscription, membership and audit in one transaction. If the subscription moved between claim and settlement (notice given, plan changed, row deleted), the money still settles and the row is marked for settlement review instead of being dropped.
- Side effects — receipt/failure email, lifecycle events for automations, booking sweep — are a durable outbox recorded per effect (
metadata.billingSideEffects), so a partial failure never repeats an effect that already happened. Emails carry a frozen payload and a stable provider idempotency key; retries stop before the provider’s dedup window closes and hand the row to review.
Recovery. RecurringChargeService.recoverBillingAttempts and BillingCollectionService.recover run every five minutes. They ask Cardcom for the attempt by external id on the attempt’s original terminal (never today’s terminal) and apply only authoritative success evidence; a lookup that finds nothing leaves the hold in place.
Retries. A confirmed issuer decline puts a renewal or a presale first charge in debt at once (billingSettlement, day 0) and later declines follow the f(N) retry schedule — see Unpaid payment. Technical failures never enter that ladder: a released technical failure is retried only when the owner authorizes it, at most three times, 5 / 15 / 60 minutes apart (TECHNICAL_RETRY_DELAYS_MS), each reusing the original external id lineage.
Alerts. BillingAttemptService.alert logs and sends a Sentry message (kind: member-billing.reconciliation, fingerprinted per org / subscription / reason), deduplicated per subscription and reason across replicas via Redis SET NX. A Redis failure alerts anyway.
Collection execution
- The daily sweep (
RecurringChargeService.handleRecurringCharges,0 2 * * *UTC,next_charge_date <= now) routes each due row per the rules above; non-protected rows keep the legacy flow described in Recurring charge flow. - The catch-up collector (
BillingCatchupService.collectDue, every minute, dedicatedbilling-collectionqueue) collects due protected obligations without waiting for the next daily run. It never falls back to legacy billing. Per pass: at most 20 organizations and 5 rows per organization, a 30-second start budget, Redis fairness cursors that survive restarts, and one flag evaluation per organization. An organization that is not opted in only loads rows whose protocol is already persisted (BillingDueQuery).
Presales
- Charge date. A presale’s first charge is never before its floor,
max(opening day, member start). Opening-day changes and staff charge-date edits take the same locks as collection (BillingScheduleService), cannot overtake an unresolved first charge, and write audit rows. - Price. The first charge uses the plan as it stands on the charge date, as legacy billing does — the presale plan is the gym’s contract with its members. Once the first charge fails, the debt is that charge’s amount and every retry charges it.
- Checkout fulfillment (
PresaleCheckoutService). With the flag on, a card-save checkout recordsmetadata.billingCheckout(page id, terminal, card-validation mode). A completion is applied only for the exact page, only on verified provider evidence, and only from the terminal that created the page; the token, the subscription’sscheduledstate at its floor and the checkout’s completion commit together, and no paid period is granted. A protected checkout that cannot be matched answers503so Cardcom redelivers, rather than falling through to a latest-pending guess. - Late completions. A checkout released by the abandoned sweep is revived exactly as legacy revives it. A checkout closed as page expired (Cardcom reports a failed in-form attempt as a no-deal
payment.failed) is markedcheckoutPageExpiredAt; a later verified completion for that page reopens it and lands the presale once. Any other closed attempt is not reopened and raisespresale_checkout_attempt_cancelled/presale_checkout_transaction_failed. - Funnel event. A presale landing (
pending→scheduled, on either the legacyhandlePaymentCompletedpath orPresaleCheckoutService) emitspayment.presale_soldonce, neverpayment.subscription_activated— nothing was activated. It is the purchase funnel’s last step for a gym that has not opened; replays never re-emit it. - Card replacement. Replacing a card re-points a
scheduledpresale at the new card under the same locks. A protected scheduled card cannot be removed while referenced. A presale whose first charge failed is already in debt; a new card is used by the next retry or the member’spay-debt. - First charge fails. A declined first charge, or a card expired or missing on the charge date, follows the renewal rule: the presale starts that day as if paid (
starts_oncleared, period and credits granted), enters debt for the agreed presale price, retries run per f(N) and on day N the plan is cancelled for non-payment. On day 0 apresale_charge_failedretention case also opens (urgentpayment_follow_uptask to the org’s active owner, skipped with a warning when there is none) so staff can call the new member. A capped presale keeps its seat during grace. A presale cannot book before its start date (plan_not_started).
Saved cards and terminals
Cardcom tokens are bound to the terminal that created them. Cards saved since TKN-353 store that terminal inside the encrypted card payload (terminalNumber). Older cards are placed by the organization’s own terminal history (cardTerminal, billing-card-terminal.ts): a card saved at least one page lifetime (45 minutes, the Cardcom page timeout plus margin) after the active config’s terminal_since was created on today’s terminal. The protected collector, desk/debt collection and readiness all use that answer; a card neither bound nor provable is refused (payment_method_account_unverified), and a card bound to another terminal is refused (payment_method_account_mismatch) — the history never overrides a stored binding. An unprovable card must be verified (see readiness) or replaced.
Desk charges, debt and legacy writers
- With the flag on, desk charges and debt collection use the same durable claims and terminal exclusion. A desk charge requires a UUIDv4
idempotencyKey; the same key with a different amount/member/description is rejected. - Legacy writers that insert a pending row before calling the provider (desk, debt, plan change, staff enrollment, early/daily renewal, cancellation tail) take the organization lock first (
withLegacyBillingAdmission) and are refused while a protected claim holds that member — or, for Cardcom, while any unresolved Cardcom money holds the organization. Admission only; their outcome handling is unchanged.
Operator reconciliation (gym owners)
/organizations/:orgId/payments/billing/transactions/:transactionId — GET (inspect), POST reconcile (look the attempt up at the provider and repair a verified success without a new debit), POST confirm-no-debit (owner only, rechecked under the money lock: record Cardcom’s written confirmation of no debit and no later settlement, and release the hold), POST authorize-retry (one technical retry). Rendered by Review billing in the org ledger and member payment history; protected pending/cancelled attempts show it instead of Dismiss. Only persisted v1 attempts are accepted.
Readiness (Taikan operators only)
A local, read-only preflight of presales awaiting their first charge — expected amount, floors, card expiry and terminal binding, unresolved and legacy pending money — plus two money-free repairs: verify-account (bind a historical token to the current terminal only when Cardcom GetLpResult returns the same token) and repair-schedule (move a missing or too-early first charge to its floor; 409 billing_schedule_not_anomalous otherwise). Served only at /admin/organizations/:orgId/billing-readiness behind PlatformAdminGuard and rendered on the admin app’s organization page; gyms never see it. Repairs write an org audit event attributed to the operator’s Taikan user. Details and 409 codes: runbook, Readiness inspection.
Managed members
Spec: managed-members/behavior.md §4.2. Ungated.
- Payer:
PayerResolverServiceresolves who is liable:subscriptions.payerUserIdwhen set, else the member (apps/api/src/payments/services/payer-resolver.service.ts:41-60). Receipts and tax receipts are addressed to the payer (tax-receipt.service.ts). - Cards:
member_payment_methods.ownerUserIdrecords whose card it is; a card used for a child’s plan belongs to the adult, and card notices go to them (payment-method.service.ts). - A parent’s Payments screen lists only their own charges; a child’s arrive as emailed receipts.