Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPaymentsPayments — Behavior

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):

ModeWhat happensCredentials
Taikan managedTaikan opens a Cardcom terminal in the gym’s name under its dealer agreement.Never asked for — they come back from Cardcom.
Bring your ownThe 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:

  1. 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.
  2. Apply. ManagedTerminalForm collects ~15 fields (business, owner, bank account) and POSTs to /organizations/:orgId/payment-config/provision. The builder expands these into the ~70 Cardcom NewCompany asks — see cardcom-provisioning.builder.ts.
  3. 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' with isActive = true. getDecryptedCredentials filters on status = '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 on status, never on isActive: shop buy and plan-step purchase, the pricing-editor purchase readiness hint, canCharge and temporary pricing on the member’s plans tab, refund execution and external-billing checks all read the payment config’s status / provider directly. Plans authoring (plans-content.tsx, plan-detail-page.tsx) deliberately reads isActive, 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, hook useBilling()): payment config status = '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 the paymentConfig permission. 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 provisionTerminal throws Conflict while a pending_kyc config 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):

TriggerWhoWhat it does
@ScheduledJob(…, '30 * * * *')systemPolls 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/refreshowner/adminSame 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-statusplatform adminThe 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:

  • ResponseCode describes the query, not the application. A non-zero code leaves the terminal pending and changes nothing.
  • IsDone: true is the approval signal. On approval the charging credentials are re-encrypted from that response (APIUserName / APISecret) rather than trusting what NewCompany returned, the status flips to active, and only then does the activation email go out.
  • DocumentsErrorResults / KycErrorResults / CompanyErrorResults / UserErrorResults are flattened into status_reason and 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):

FieldWhat it isWhere to get it
apiKeyId / apiKeySecretAPI key pair → OAuth client_id / client_secret (morning.provider.ts:186).Morning dashboard → API keys.
pluginIdIdentifies the payment plugin used to open hosted pages (morning.provider.ts:493).See lookup below.
webhookSecretCompared 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:

  1. Log into the Morning dashboard and open /market/payments.
  2. Open DevTools → Network and locate the request to /api/v1/plugins.
  3. In that response, the plugin’s id is the pluginId to 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/plugins response, extract the id, 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.

FromToTriggerFile
newpendingCharge initiated, provider page issuedpayment.service.ts:176 (upsertPending)
pendingcompletedpayment.completed webhook OR verify-returnwebhook-processing.service.ts:121
pendingfailedpayment.failed webhook OR adapter returns success=falsewebhook-processing.service.ts:325
pendingcancelledSubscription cancelled with in-flight pending chargesplatform-billing.service.ts:749 (mirror pattern)
completedrefund_pendingrefund() against manual adapter — opens manual_refund taskpayment.service.ts (openManualRefundTask)
completedrefundedrefund() against automatic adapter once completed refunds add up to the whole sale, OR refund.completed webhookpayment.service.ts (processAutomaticRefund), webhook-processing.service.ts:528
completedrefundedCash credit notes add up to the whole sale (a partial credit leaves it completed)cash-credit-note.service.ts (flipWhenFullyCredited)
refund_pendingrefundedOwner 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 (withSubParam in payment.service.ts, idempotent so the mobile bridge’s own sub isn’t duplicated, and Morning’s appendQuery now 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 marked failed (webhook-processing.service.ts, handlePaymentFailed), and a payment.activation_failed event is emitted with reason: 'first_charge_failed'. It used to flip pending → cancelled with NO abandoned_checkout marker, 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 FailedRedirectUrl and CancelRedirectUrl at the same cancelUrl, so a declined card told the member they had cancelled. The hosted-payment input now carries an optional failedUrl (PaymentPageRequest), derived from cancelUrl by flipping status=cancelled → status=failed when the client doesn’t send one (payments/checkout-urls.ts, deriveFailedUrl). The mobile deep-link bridge forwards the param untouched, so status=failed survives into the app. Morning is deliberately unchanged: its API has a single failureUrl used 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 flip pending → cancelled, stamped cancellationReason='abandoned_checkout' so a late payment still revives it (webhook-processing.service.ts’s revivingAbandonedCheckout):

    • sweepAbandonedCheckouts cron (payment-monitoring.service.ts), on whichever fuse applies:
      • 24h idle normally. Idle is measured from the newest OPEN attempt (payment_transactions still pending), falling back to subscriptions.created_at — anchoring on the subscription alone would sweep a two-day-old row the member had just re-opened via resume-checkout, cancelling it while they looked at the payment form.
      • 1h idle when the provider’s last word was a decline (a failed transaction 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. A failed row followed by a newer pending one 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. detectStuckPending still sees declined rows but emits them outcome: 'ignored' so they do not page — they are pending on purpose now;
    • 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 stay abandoned_checkout for 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.

StepEndpointEffect
Report the landingPOST …/subscriptions/my/:id/checkout-returnTelemetry 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.
ResumePOST …/subscriptions/my/:id/resume-checkoutFresh 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 backPOST …/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_attempts counts declines. An expired or missing card does the same without a charge attempt (markCardUnusable). See Unpaid payment.
  • Membership payment_status mirrors the subs: current | debt (past_due is legacy, no longer written). Bookings do not read it: they judge every live subscription through the billing gate.

Charge flow (hosted page)

  1. PlansController.purchase → PlansService.purchase (apps/api/src/plans/plans.service.ts:200).
  2. Resolves/creates a pending subscription. Reuses pending row on retry (no duplicate inserts).
  3. 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_transactions row keyed by subscriptionId (payment-transaction.service.ts:154) — never duplicates on retry.
  4. Returns { paymentPageUrl, processId }. The client hard-redirects.
  5. Provider POSTs the webhook → payment-webhook.controller.ts validates signature → WebhookProcessingService.processWebhookEvent (webhook-processing.service.ts:42):
    • Looks up the pending txn by providerTransactionId; if absent, falls back to completePendingBySubscriptionId.
    • Activates the sub inside a DB transaction: status active, advances currentPeriodStart/End, refills remainingCredits.
    • Stamps the card token to member_payment_methods (replace existing, mark old inactive — replacePaymentMethod).
  6. On user return to successUrl, the web app calls POST /verify-return (payment.controller.ts:42). Idempotent fallback: re-fetches Cardcom’s GetLpResult and 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:

  1. A5 advisory lock (FIT-254 review Wave A): the whole sweep first attempts pg_try_advisory_lock on a fixed key; if another run already holds it, this run logs a warning and returns immediately — no SELECT, no charges. FOR UPDATE SKIP LOCKED alone 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.
  2. SELECT … FOR UPDATE SKIP LOCKED on subscriptions 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’s next_charge_date stays NULL forever (it’s never billed), so the original next_charge_date <= now predicate alone could never select it once its schedule fell due.
  3. Per row: if scheduled_plan_id is set, swap the plan first (see the boundary-swap section linked above) — A2(a): a card-less sub after the swap is billed via NEITHER getActivePaymentMethod NOR any charge attempt (it’s a swap-only visit that advances its own period in the swap transaction); a carded sub proceeds as below.
  4. Insert pending txn first (the “record before charge” invariant), then adapter.createCharge(token, …), then update txn status.
  5. Success → handleChargeSuccess: advance period, reset counters, set membership payment_status = current. A3 exception: a row that went through the boundary swap this tick does NOT get remainingCredits refilled to the raw allotment — the swap’s in-place sweep already set it to (allotment − kept-booking deductions); refilling would erase that.
  6. Failure → handleChargeFailure: the first decline enters debt (SUBSCRIPTION_DEBT_OPENED, notice by event); a declined retry moves nextChargeDate to the next retry instant and sends paymentFailedEmail. 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 PaymentTransactionService create a recurring intent before charging, with source: cancellation_tail and a unique cancellation-tail:<subscriptionId>:<effectiveAt ISO> key. updateStatus preserves 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:

GateWhereMeaning
adapter.getRefundCapability() === 'automatic'in-memoryThe adapter declares a verified refund API. No provider does this yet.
isManagedTerminal(orgId)one DB readTaikan opened this terminal under its own dealer agreement (FIT-286), so the refund is ours to make.
RefundCapabilityService.canRefundThroughProviderin-memoryA 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 is external (ADR-0017), or the active provider simply has no registered adapter (e.g. test) — none of which Taikan can call at all. Resolved via registry.get(), never getOrThrow(), so an unregistered-but-configurable provider type reads as unavailable instead 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):

  1. 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; 409 refund_amount_exceeds_cap { capInCents }, nothing written, provider not called).
  2. Insert the refund row as pending before calling the provider, carrying the original’s subscription_id and plan_id and metadata: { source: 'automatic_refund', refundedTransactionId, reason }, so a parallel refund sees it in the cap and refund totals per sale are a query.
  3. Call adapter.refund(...) with the adapter and credentials resolved for the transaction’s own provider, then settle the row (completed / failed). The Cardcom adapter returns outcome: 'refused' for a non-zero ResponseCode and outcome: 'unknown' for an HTTP error, timeout or a body without a code; an adapter that throws counts as unknown.
  4. On success the original flips to refunded only once its completed and reserved refunds (priorCreditsInCents) reach its amount; a partial refund leaves it completed. This partial-flip fix goes beyond the written TKN-425 decisions (plan assumption A9) and was flagged to the owner.
  5. Member email refundIssuedForTransaction (skipped on notifyMember: false), then the credit document — best-effort, see below.
  6. Audit payment.refund_issued (capability: 'automatic'); observability payment.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:

  1. Open, under the same sale lock and cap as the automatic path: refuses if refundTaskId is already set (400 Refund already in progress for this transaction) or the requested amount exceeds the cap (409 refund_amount_exceeds_cap, nothing opened). Creates a tasks row with type='manual_refund', priority='high' and a 3-day due date, and writes original.metadata.manualRefund = { taskId, requestedAmountInCents, reason, origin, openedAt }. The original goes to status='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 carries checkPortalFirst: 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 by flagTransactionForManualReview; requests whatever is still refundable.

    Also sends CancellationNotificationsService.refundProcessing(txnId, { amountInCents }) to the member — best-effort, never blocks the task.

  2. 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.

  3. Close: POST /organizations/:orgId/payments/refund-tasks/:taskId/complete body { 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') → 409 refund_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 → completed with completedById; a completed refund row is inserted with the original’s provider and method, refundExternalReference = the document number, metadata: { source: 'manual_refund_task', refundedTransactionId, taskId }. The original becomes refunded once refunds reach 100% (keeps refundTaskId); a partial close leaves it completed with refundTaskId = null and manualRefund.closedAt, so another task can be opened for the rest. Audit stage: 'settled' with partial. A replay of a completed task writes nothing and returns the earlier result.

  4. Notification: refundIssuedForTransaction to 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 refunded only 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 refunded only 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:

Callermetadata.sourceRefund row idempotency_keycashReturned
Credit note — POST /organizations/:orgId/payments/:txnId/credit-notecredit_notecash-credit:<orgId>:<client key>staff choice
Staff ends a cash plan with a credit (ManualPaymentService.cancelCashPlan)cash_plan_cancellationcash-cancel:<subscriptionId>:<saleId>staff choice, default true
Confirm cash returned — POST /organizations/:orgId/payments/:refundTxnId/settle-cash-refundcash_notice_settlementthe reservation’s own cash-notice:<subscriptionId>:<saleId>always true
POST …/payments/:txnId/refund on a manual transaction (ManualPaymentService.refund)manual_payment_refundmanual-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, status completed or refunded. Otherwise 409 credit_note_not_available. refunded is 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_number present, no document_error, metadata.cashDocumentSkipped not set. Otherwise 409 credit_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 documentCredit kindCardcom filesMorning files
Cardcom TaxInvoiceAndReceipt / 1tax_invoice_receipt_creditTaxInvoiceAndReceiptRefund (type 2)—
Cardcom Receipt / 3receipt_creditReceiptRefund (type 4)—
Morning 320 / 305tax_invoice_receipt_credit—330
Morning 400receipt_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.refundedTransactionId is the original and whose status is completed or refund_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’s cancelled_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.

  • 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 refunded only when its credits reach exactly its amount (flipWhenFullyCredited); a partial credit leaves it completed.

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 with replayed: true and writes, audits and files nothing again; the same key with a different payload → 409 credit_note_idempotency_conflict. The client’s key is kept on metadata.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’s method, membership_id, subscription_id, plan_id and 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 (Cardcom Cash for cash), line item = the staff reason (החזר תשלום when there is none, and always for a cash-notice settlement), the member’s name and email (sendByEmail when an email exists — Cardcom omits an email over 50 characters), Comments = זיכוי למסמך מס' <original number> on Cardcom, and documentDate = the org-local day of paid_at. Cardcom receives DocumentDate (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 writes document_provider, document_type, document_number, document_id, document_url, document_issued_at on the refund row and clears document_error; failure leaves document_error set — 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). Observability payment.refund_completed with capability: '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: provider manual, not a refund, completed or refunded) shows Credit note instead of the refund button, for staff with payments/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_error is 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}), opening confirm-cash-returned-dialog.tsx with 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

Statesubscriptions rowmemberships.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 fordebt
Cancelled for non-paymentstatus='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 opendebt (the debt is still owed)
Restoredstatus='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 cardcurrent 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

SourceWriterdebt_reasonAmountPeriod
Card renewal declinedlegacy sweep handleChargeFailure, protected billingSettlement, webhook handlePaymentFailedcharge_failedthe 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 timemarkCardUnusable (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 committedcard_unusablesamesame
Presale first charge declined, or card unusablesame writerscharge_failed / card_unusablethe agreed presale pricethe presale starts that day (starts_on cleared, quota anchored at day 0); see Presales
Final cancellation bill declinedCancellationTailSettlementServicecharge_failedthe 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 recordedlifecycle jobcash_lapsedthe next period at the agreed price[cash_prepaid_through, +interval]; day 0 = cash_prepaid_through
Pay-later term passed its due datelifecycle jobpayment_dueunpaid_amount_in_centskept (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).

RailPath
Card retry succeedsbillingSettlement / handleChargeSuccess / webhook handleSubscriptionRenewed on a row with debt_since → debtClearedPatch, SUBSCRIPTION_RENEWED
Card, staffPOST /organizations/:orgId/subscriptions/:id/clear-debt → DebtService.clearDebt (accepts debt or cancelled for non-payment); protected path: BillingCollectionService
Card, memberPOST /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_debt with billingDebt), even beside a healthy plan. Staff bypass. See scheduling-bookings › Billing gate.
  • GET /organizations/:orgId/subscriptions/my and the staff subscription list — every row carries billingDebt: { amountInCents, since, reason, payBy, cancelsAt, state: 'grace' | 'cancelled' } | null and paymentLastDay; memberActions carries pay_debt for 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, billingDebt attached); a non-course plan cancelled for non-payment with debt puts the member in non_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_debt is 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 except card_unusable): email debtOpenedEmail, push billingDebtOpened (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: email cardUnusableEmail, push billingCardUnusable with amount and pay-by day; counts as the opened stage.
  • 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; Redis SET NX per card and day. Email cardExpiringEmail, push billingCardExpiring.
  • A declined retry sends paymentFailedEmail with 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:

FormUsed byWhy
POST /webhooks/payments/:provider/:orgIdCardcom, iCredit, Meshulam, TranzilaProvider accepts per-payment notify URL; we encode orgId in the path.
POST /webhooks/payments/:provider?org=…MorningProvider 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:

  1. 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 in verifyWebhookOutOfBand, and the provider’s answer replaces the body as the event. Either success stamps metadata.providerVerified = true on 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.
  2. Enforcement (controller, WebhookEnforcementService). For a non-transient failure (bad signature, a page Cardcom cannot place) the per-org flag payment-webhook-signature-enforcement decides the HTTP answer: on → 400 and payment.webhook_rejected with outcome: failure; off → 200, the body still reaches the processor, and payment.webhook_rejected is 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.
  3. The processor refuses unverified events (WebhookProcessingService.processWebhookEvent). Before the cross-org guard and before any handler, an event without providerVerified: true is logged, Sentry-captured (unverified payment webhook refused, fingerprinted per provider and org), emitted as payment.webhook_ignored with reason: '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

MechanismWhere
txnByProvider.status === 'completed' short-circuitwebhook-processing.service.ts:121
State-machine guard “refuse anything not pending→terminal”platform-billing.service.ts:313
Refund task completed → alreadyCompleted: true no-oppayment.service.ts:599
Cash credit note: refund row idempotency_key replay; payload mismatch → 409 credit_note_idempotency_conflictcash-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 subscriptionmanual-payment.service.ts (unpaidSettlementKey, record)
Unique-by-provider-id index on platform_billing_transactionslibs/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

ScenarioWhat happensRecovery
Provider returns success but DB write throws after adapter callAdapter 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 outWe 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 arrivesverify-return POST from the success page replays the same processing logic.Member or owner can hit /verify-return again.
Multiple webhooks for the same processIdShort-circuit on completed status.None needed.
Refund task closed twiceService guard returns alreadyCompleted: true.None.
Refund task externalReference is wrongNo verification — we trust the gym owner.Edit task description, no automated repair.
Credit committed, credit document not filedThe 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 taskRefused, 409 refund_task_use_cash_settlement; nothing written.Confirm cash returned on the reservation row.
Member closes the hosted form / the card is declinedNo 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 meanwhileresumeCheckout 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 completesThe 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 itThe 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

ActionSide effect
Charge succeededsendPaymentReceipt 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 closedCompleted 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 issuedRefund 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 / unusableMember email + push under the billing preference — see Member notices.
Member gives cancellation noticeMember 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 memberpayment.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 unpaidpayment.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.

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.lowProfileId against the pending row’s providerTransactionId, which PaymentService set to Cardcom’s LowProfileId at creation), so an old page expiring late never closes a newer one the member is typing into;
  • only for a still-pending subscription, org-scoped through the membership;
  • marks the attempt cancelled (the same marker the supersede path uses), never failed;
  • emits payment.webhook_ignored with reason: 'checkout_page_expired' and stops — no activation_failed, no Sentry, subscription left pending for 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 root ResponseCode is 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 our LowProfileId, our terminal and our ReturnValue. 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-zero LowProfileId and terminal 0, captured on production 2026-09-28 — is refused as deal_not_found. A body claiming success for a page Cardcom reports unfinished is answered 503 (deal_not_finalized) so Cardcom’s redelivery settles it. Before 2026-09-28 every known-page failure read as deal_not_found: 40 webhooks since 2026-08-15, all failed or timed-out attempts, none a success. Under enforcement they would have been answered 400, which Cardcom redelivers like any non-200 (7 retries over ~25h) while the attempt stayed pending.

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:

ReaderExpired page (cancelled)Refused card (failed)
detectStuckPendingoutcome: '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 sweepcause: '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, and lead_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/me now returns rejoinToken (and rejoinPlanId, the released checkout’s plan, so the button reopens that offer) on a membership that can be redeemed back, and RoleRouter turns that screen into an action. The API owns eligibility: cancelled and join_link only, only when the person never committed to a subscription (isUncommittedRegistrant — staff removal writes the same cancelled, 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 — acceptJoinLink refuses a suspended membership, so the button would fail. resolveRejoinToken is 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. The user.created tracking.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 $identify lands first and PostHog then refuses to merge an identified person — which is why the web side switched keys rather than relying on it.

Permissions

ActionRouteRequired role
Configure providerPOST /organizations/:orgId/payment-configowner or admin (payment-provider-config.controller.ts:29)
Refund a transactionPOST /organizations/:orgId/payments/:txnId/refundowner or admin, payments/manage (payment.controller.ts); a manual transaction also needs idempotencyKey
Preview / issue a cash credit noteGET …/payments/:txnId/credit-note/preview, POST …/payments/:txnId/credit-noteowner or admin, payments/manage (payment.controller.ts, rechecked in CashCreditNoteService)
Confirm cash returned (settle a cash-notice reservation)POST …/payments/:refundTxnId/settle-cash-refundowner or admin, payments/manage
Close manual_refund taskPOST /organizations/:orgId/payments/refund-tasks/:taskId/completeowner or admin
Settle / write off an unpaid termPOST /organizations/:orgId/subscriptions/:subscriptionId/settle-unpaid, …/write-off-unpaidowner or admin, payments/manage (manual-payment.controller.ts)
List org transactionsGET /organizations/:orgId/paymentsowner or admin
List my transactionsGET /organizations/:orgId/payments/myany active member
Acknowledge / contact / set outcome / withdraw a retention casePOST /organizations/:orgId/subscriptions/:id/retention/*, …/withdraw-noticeowner or admin, subscriptions/manage — see subscriptions-plans/behavior.md
Cancel subscription (admin)POST /organizations/:orgId/subscriptions/:id/cancelowner or admin (subscriptions.service.ts:302)
Register card for a memberPOST /organizations/:orgId/members/:id/register-cardowner or admin (card-registration.controller.ts:44)
Change platform tierPATCH /organizations/:orgId/tierowner only (platform-tiers.controller.ts:64)
List member payment methodsGET /organizations/:orgId/members/:membershipId/payment-methodsowner or admin, payments/view
Manual charge (desk charge)POST /organizations/:orgId/members/:membershipId/chargeowner or admin, payments/manage (manual-charge.controller.ts:13)
Clear debtPOST /organizations/:orgId/subscriptions/:subscriptionId/clear-debtowner or admin, payments/manage (manual-charge.controller.ts:31)
Inspect / reconcile a protected attempt, authorize one retryGET …/payments/billing/transactions/:transactionId, POST …/reconcile, POST …/authorize-retryowner or admin, payments/manage (billing-resolution.controller.ts)
Confirm no debit and release a protected holdPOST …/payments/billing/transactions/:transactionId/confirm-no-debitowner only, rechecked under the money lock (billing-resolution.service.ts)
Presale readiness report and repairsGET/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):

  1. Permission payments/manage (owner/admin).
  2. Membership exists in org (cross-org → 404) and status === 'active' — otherwise 409 MEMBERSHIP_NOT_ACTIVE.
  3. A provider config exists for the org — otherwise 400 "No payment provider configured for this organization" (BadRequestException, not a structured PaymentErrorCodes entry — the existing idiom card-registration.controller.ts also uses). Note: the FIT-254 spec’s §3.3 table says this should be 409 no_payment_provider; the shipped code throws plain 400. Documented here as the actual behavior — see the ADR for the discrepancy.
  4. Provider is in CHARGE_VERIFIED_PROVIDERS (manual-charge.service.ts:34: ['cardcom', 'meshulam', 'test']) — otherwise 409 PROVIDER_CHARGE_UNVERIFIED.
  5. 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:

  1. Charge-before-record → pending-row-first. The payment_transactions row is now inserted BEFORE adapter.createCharge (mirroring manual-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.
  2. 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 concurrent clearDebt calls 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 via PaymentService.flagTransactionForManualReview (a thin wrapper around the existing manual_refund task-open helper), which flips that transaction to refund_pending and opens a manual_refund task so an owner reconciles the double charge.
  3. Never bills again → nextChargeDate restored. On success, nextChargeDate is set to currentPeriodEnd (the normal billing date; null for non-recurring plans or with notice given) and failedChargeAttempts reset to 0 — previously nextChargeDate stayed NULL forever 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

  1. Claim. Under organization → membership → subscription locks (lockBillingAccount), the collector inserts a pending ledger row carrying metadata.billingAttempt (version 1): obligation, amount, currency, original terminal and a compact external attempt id (≤ 25 characters, sent to Cardcom as ExternalUniqTranId). A request the gateway could never have received (no token, no expiry, unusable amount/currency) fails before any claim exists.
  2. 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) or blocked (unresolved money needs a human).
  3. Outcome. An issuer decline, a technical rejection and an unknown result (timeout, lost response) are separate outcomes. Unknown stays pending and holds the member; it is never counted as a decline and never retried blindly.
  4. 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.
  5. 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, dedicated billing-collection queue) 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 records metadata.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’s scheduled state at its floor and the checkout’s completion commit together, and no paid period is granted. A protected checkout that cannot be matched answers 503 so 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 marked checkoutPageExpiredAt; a later verified completion for that page reopens it and lands the presale once. Any other closed attempt is not reopened and raises presale_checkout_attempt_cancelled / presale_checkout_transaction_failed.
  • Funnel event. A presale landing (pending → scheduled, on either the legacy handlePaymentCompleted path or PresaleCheckoutService) emits payment.presale_sold once, never payment.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 scheduled presale 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’s pay-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_on cleared, 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 a presale_charge_failed retention case also opens (urgent payment_follow_up task 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: PayerResolverService resolves who is liable: subscriptions.payerUserId when 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.ownerUserId records 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.