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.

Two 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 on status, never on isActive.
  • 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.

Rollout gate

The whole managed path — the mode choice in settings and the provision endpoint — was behind the PostHog flag taikan-managed-payments, which was merged permanently ON on 2026-08-22 and deleted. Both are now available to every org. The irreversibility that made the flag fail-closed has not gone away: provisionTerminal still 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
@Cron('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:535
completedrefundedrefund() against automatic adapter, OR refund.completed webhookpayment.service.ts:455, webhook-processing.service.ts:528
refund_pendingrefundedOwner closes manual_refund task with externalReferencepayment.service.ts:622

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, ├──(payment.failed, attempts<3)──► past_due │ subscription STAYS pending) │ │ │ │ (retry day 3/7/14 succeeds) ├──(member resume-checkout)──► pending │ │ │ (fresh hosted page, same row) ◄───────────────────────────────────────┘ │ │ └──(24h sweep | owner void-txn | ├──(attempts==3)──► debt 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)
  • 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=cancelledstatus=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) — unflagged; the subscription-member-cancel-pending gate reached 100% and has been removed.

    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.resumeCheckoutpurchase(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 counter lives at subscriptions.failed_charge_attempts. Next retry chosen from RETRY_INTERVAL_DAYS = [3, 7, 14] (recurring-charge.service.ts:21).
  • On the 3rd consecutive failure the sub flips to debt, debtAmountInCents is incremented by the plan price, debtSince stamped, nextChargeDate = null (recurring-charge.service.ts:246). The org owner must then trigger a clearDebt flow (debt.service.ts).
  • Membership payment_status mirrors the sub: current | past_due | debt. Used by bookings to refuse class entry.

Charge flow (hosted page)

  1. PlansController.purchasePlansService.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

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') 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: bump attempts, set nextChargeDate from RETRY_INTERVAL_DAYS, fire payment-failed or debt-warning email.

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.

Refund flows

Entry: PaymentService.refund(orgId, txnId, request).

Refused outright on the external tier. Before any 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.
payment-automatic-refunds flagPostHog, per-orgPer-org opt-in for a bring-your-own terminal whose credentials do carry refund permission. RefundCapabilityService.

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.

The flag is default OFF and fails toward manual: off, unset, or unevaluable (non-prod, PostHog unreachable) all keep the manual task. It is additionally refused when txn.provider !== the org’s active config provider — refunding a pre-provider-switch charge would send its providerTransactionId to a gateway where that id means nothing, or names someone else’s transaction. Only cardcom is flag-eligible; the flag can never reach an adapter whose refund() is a stub.

Note getRefundCapability() returning manual for Cardcom means “unverified against a live terminal”, not “unimplemented” — CardcomProvider.refund calls v11 RefundByTransactionId and handles its response codes. The flag is how that verification gets done, one org at a time.

Automatic capability (no provider declares this yet; reached via the other two gates)

payment.service.ts:427. One transaction:

  1. Call adapter.refund(...).
  2. On success: original txn → refunded, insert a new refund type txn linked to the original via metadata.
  3. Emit payment.refund_completed observability event.

Manual capability (Cardcom, iCredit, Meshulam, Morning, Tranzila)

payment.service.ts:501. Two phases:

  1. Open: refuses if refundTaskId already set (payment.service.ts:508). Creates a tasks row with type='manual_refund', priority='high' and a 3-day due date. Updates the original txn to status='refund_pending', refundTaskId=task.id.
  2. Close: POST /organizations/:orgId/payments/refund-tasks/:taskId/complete body { externalReference }. Inside a DB transaction (payment.service.ts:588):
    • Task → completed with completedById and timestamp.
    • Linked txn → refunded, stores refundExternalReference (credit-doc number).
    • Idempotent: if the task is already completed the call returns { alreadyCompleted: true } without mutating.
  3. Notification: CancellationNotificationsService.refundCompleted to the member (payment.service.ts:656).

Partial refunds

request.amountInCents is optional. Default is full charge. There is no schema-level enforcement that sum(refund.amountInCents) ≤ charge.amountInCents — see qa-plan.md.

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.

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 in payment-webhook.controller.ts:109. A failed signature throws 400 BadRequest; the request never reaches the processor.

Defence-in-depth where signatures are absent (Cardcom, Morning):

  • The webhook URL embeds the orgId (path or ?org=). Spoofing requires guessing a UUID.
  • The webhook body’s metadata.subscriptionId (or courseEntitlementId) must match a real row; cross-org abuse is blocked explicitly for course entitlements at webhook-processing.service.ts:215 and :362.
  • For Cardcom we re-fetch the truth from GetLpResult before mutating (platform-billing-webhook.controller.ts:73; the per-org payments controller relies on the IPN body but verify-return re-fetches).

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 completedalreadyCompleted: true no-oppayment.service.ts:599
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.
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, observability event payment.subscription_activated / payment.renewal_succeeded.
Charge failed (renewal)paymentFailedHtml or debtWarningHtml email (recurring-charge.service.ts:281), membership payment_status flipped, sub status flipped.
Refund initiated (auto)payment.refund_initiated event, original txn → refunded, new refund txn inserted, no email yet (the automatic branch in cancellation-requests.service.ts:283 doesn’t fire a “refunded” email — the manual branch’s task-completion does).
Refund task opened (manual)payment.refund_task_opened event, tasks row created (priority=high).
Refund task closedMember email cancellationNotifications.refundCompleted (payment.service.ts:656), observability payment.refund_completed.
Cancellation request approvedSub cancelled, refund issued via the capability-aware path, member email cancellationRequestApproved (cancellation-requests.service.ts:320).
Cancellation scheduled at period endMember email cancellationScheduled, cancellation_review task created (priority=low).
Presale withdrawn (scheduledcancelled)payment.subscription_cancelled with source: 'member_presale_withdraw', audit row (mode: 'presale_withdraw'), pending transactions cancelled, seat released. No email, no form, no review task — nothing was charged and no membership ever started, so every existing cancellation artifact would say something untrue.
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 — behind checkout-recovery-emails — 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). Logs go through PaymentObservabilityService.emit(...) — the consumer is payment-monitoring.service.ts plus Sentry. No separate audit table.

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. For the expiry shape GetLpResult answers a non-zero ResponseCode, which reads as deal_not_found — so today the expiry only reaches processing through the unverified body, i.e. while payment-webhook-signature-enforcement is off for the org (the default). Under enforcement it is rejected, and the page’s attempt stays pending until the release sweep closes it; the outcome is the same, one sweep later.

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 the deal is only read when the envelope’s ResponseCode is 0, 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

Behind checkout-recovery-emails (per-org, default OFF, gated separately from join-membership-release — an org may want the seat freed long before it wants Taikan writing to its members in its name).

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 pendingcancelled 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

  • PostHogpayment.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 (payment.controller.ts:29)
Close manual_refund taskPOST /organizations/:orgId/payments/refund-tasks/:taskId/completeowner or admin
List org transactionsGET /organizations/:orgId/paymentsowner or admin
List my transactionsGET /organizations/:orgId/payments/myany active member
Approve cancellation requestPOST /organizations/:orgId/cancellation-requests/:id/approveowner or admin (cancellation-requests.service.ts:362)
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, flag admin-card-on-file (manual-charge.controller.ts:13)
Clear debtPOST /organizations/:orgId/subscriptions/:subscriptionId/clear-debtowner or admin, payments/manage, flag admin-card-on-file (manual-charge.controller.ts:31)

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:13ManualChargeService.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. PostHog flag admin-card-on-file, evaluated per-org (organization group). Fail-closed: only a literal true passes; unset/unreachable/false → 403, before any DB/provider work.
  2. Permission payments/manage (owner/admin).
  3. Membership exists in org (cross-org → 404) and status === 'active' — otherwise 409 MEMBERSHIP_NOT_ACTIVE.
  4. 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.
  5. Provider is in CHARGE_VERIFIED_PROVIDERS (manual-charge.service.ts:34: ['cardcom', 'meshulam', 'test']) — otherwise 409 PROVIDER_CHARGE_UNVERIFIED.
  6. 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.tsManualChargeService.clearDebt). Same guard stack as manual charge (flag → permission → provider-config-exists-as-400 → allowlist), then wraps DebtService.clearDebt: charges the full debtAmountInCents on the active card, resets the sub to active on success. 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 status='active', debt_amount_in_cents=0, ... WHERE id=... AND status='debt' 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 max(now, currentPeriodEnd) 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.

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.