Payments — Behavior
Money is paranoid. Every flow below is documented from the source in apps/api/src/payments/.
Provider configuration
An org configures one gateway via POST /organizations/:orgId/payment-config (payment-provider-config.controller.ts:29, owner/admin only). The body is { provider, credentials, config? } (configure-provider.dto.ts). credentials is a Record<string,string> encrypted with AES-256-GCM into payment_provider_configs.encrypted_credentials (credential-encryption.service.ts); the non-secret config is stored as plaintext JSON.
Taikan-managed terminals (Cardcom, FIT-286)
Settings → Payments opens on a mode choice rather than a provider list (provider-config-dialog.tsx):
| Mode | What happens | Credentials |
|---|---|---|
| Taikan managed | Taikan opens a Cardcom terminal in the gym’s name under its dealer agreement. | Never asked for — they come back from Cardcom. |
| Bring your own | The existing POST /payment-config credentials path. icredit, morning, cardcom (for a gym that already holds a terminal). | Owner pastes them. |
The managed path is explain → apply → one-time login:
- Explain. A dedicated step states that Cardcom underwrites the application at its own discretion, that approval takes days, and that money settles from Cardcom straight to the gym’s bank — Taikan never holds funds. No fields on this step.
- Apply.
ManagedTerminalFormcollects ~15 fields (business, owner, bank account) andPOSTs to/organizations/:orgId/payment-config/provision. The builder expands these into the ~70 CardcomNewCompanyasks — seecardcom-provisioning.builder.ts. - One-time login. Cardcom returns a dashboard login for the owner. It is never persisted, so the dialog refuses to close on that step until the owner acknowledges it.
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'withisActive = true.getDecryptedCredentialsfilters onstatus = 'active', so every charging path fails closed at once. Members see a gym with no shop; staff see a pending badge and keep authoring priced plans. Clients must gate onstatus, never onisActive. - One application at a time. Every successful call opens a real company at Cardcom and nothing downstream deduplicates them, so
provisionTerminalthrowsConflictwhile apending_kycconfig exists. A rejected application can be resubmitted.
KYC volume estimates (min/max/average transaction, monthly count) are derived server-side from the org’s active plans and member count, with fallbacks for a gym that hasn’t priced anything yet. The owner is never asked — Taikan already knows the numbers, and a guess on an underwriting form is worse than a derived one. The DTO still accepts an explicit override.
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):
| Trigger | Who | What it does |
|---|---|---|
@Cron('30 * * * *') | system | Polls every pending terminal whose org has managed-terminal-kyc-poll on. Hourly on purpose — the wait is measured in business days and the dealer API is not ours to hammer. |
POST …/payment-config/managed-status/refresh | owner/admin | Same check, on demand, and never flag-gated. Exists because the SMS reaches the owner before any tick does; the settings card shows a Check status button while pending. |
POST …/payment-config/managed-status | platform admin | The manual override, and the only way to close a rejection — a declined application simply never becomes IsDone. |
The sweep is gated on managed-terminal-kyc-poll (per-org, organization group, default OFF). It ran unflagged until 2026-08-13, when it was removed for failing hourly on the wrong company identifier (FITKIT-BACKEND-3W), and came back on 2026-08-16 with the identifier corrected. The flag is the rollout control for a fix nobody has yet watched Cardcom accept: turn it on for one gym, confirm GetCompanyStatus answers ResponseCode: 0, then widen. Off, unset, or unevaluable (PostHog unreachable) leaves that org exactly where it is today — on-demand only. Two things are skipped without contacting Cardcom at all: an org whose flag is not a definite true, and a terminal with no stored companyNumber, which can never produce a successful query no matter how often it is retried. CRONS_ENABLED still gates the tick itself, as for every other cron.
What the status check reads:
ResponseCodedescribes the query, not the application. A non-zero code leaves the terminal pending and changes nothing.IsDone: trueis the approval signal. On approval the charging credentials are re-encrypted from that response (APIUserName/APISecret) rather than trusting whatNewCompanyreturned, the status flips toactive, and only then does the activation email go out.DocumentsErrorResults/KycErrorResults/CompanyErrorResults/UserErrorResultsare flattened intostatus_reasonand shown to the owner on the pending card as “still needed” — Cardcom’s four-way grouping is its own filing system, not something an owner should have to parse.
Morning (morning.co.il, formerly Green Invoice)
Morning credentials are { apiKeyId, apiKeySecret, pluginId, webhookSecret } (read in morning.provider.ts:186, :493, :953):
| Field | What it is | Where to get it |
|---|---|---|
apiKeyId / apiKeySecret | API key pair → OAuth client_id / client_secret (morning.provider.ts:186). | Morning dashboard → API keys. |
pluginId | Identifies the payment plugin used to open hosted pages (morning.provider.ts:493). | See lookup below. |
webhookSecret | Compared against the inbound webhook secret/webhookSecret body field (morning.provider.ts:953, :996). Note the registered signature check still returns true regardless — see README gaps. | Owner-chosen value, set when registering the static webhook URL. |
Getting the pluginId (manual, today): Morning exposes no labelled field for it. To find it:
- Log into the Morning dashboard and open
/market/payments. - Open DevTools → Network and locate the request to
/api/v1/plugins. - In that response, the plugin’s
idis thepluginIdto put in the Taikan config.
This is a developer-level step gym owners can’t be expected to do. Planned direction (not built): automate the lookup with an agent — authenticate into Morning, capture the
/api/v1/pluginsresponse, extract theid, and populate the config automatically as part of self-serve Morning onboarding. Design TBD (Morning auth, headless browser vs. their API, where the agent runs).
Payment transaction state machine
transaction_status enum in libs/db/src/lib/schema/enums.ts:173.
| From | To | Trigger | File |
|---|---|---|---|
| new | pending | Charge initiated, provider page issued | payment.service.ts:176 (upsertPending) |
pending | completed | payment.completed webhook OR verify-return | webhook-processing.service.ts:121 |
pending | failed | payment.failed webhook OR adapter returns success=false | webhook-processing.service.ts:325 |
pending | cancelled | Subscription cancelled with in-flight pending charges | platform-billing.service.ts:749 (mirror pattern) |
completed | refund_pending | refund() against manual adapter — opens manual_refund task | payment.service.ts:535 |
completed | refunded | refund() against automatic adapter, OR refund.completed webhook | payment.service.ts:455, webhook-processing.service.ts:528 |
refund_pending | refunded | Owner closes manual_refund task with externalReference | payment.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 (withSubParaminpayment.service.ts, idempotent so the mobile bridge’s ownsubisn’t duplicated, and Morning’sappendQuerynow skips a key it finds). The cancelled/failed page has to offer a decision about a SPECIFIC checkout, and without the id it has nothing to act on. -
First-purchase failure leaves the subscription
pending. Only the transaction is markedfailed(webhook-processing.service.ts,handlePaymentFailed), and apayment.activation_failedevent is emitted withreason: 'first_charge_failed'. It used to flippending → cancelledwith NOabandoned_checkoutmarker, which made a declined card a dead end in both directions: nothing for the member to resume, and nothing a late success webhook could revive. The charge failed; the checkout did not. If the member never comes back, the 24h sweep releases the row anyway — and does it with the marker. -
Distinct failure vs. cancel return URLs. Cardcom pointed
FailedRedirectUrlandCancelRedirectUrlat the samecancelUrl, so a declined card told the member they had cancelled. The hosted-payment input now carries an optionalfailedUrl(PaymentPageRequest), derived fromcancelUrlby flippingstatus=cancelled→status=failedwhen the client doesn’t send one (payments/checkout-urls.ts,deriveFailedUrl). The mobile deep-link bridge forwards the param untouched, sostatus=failedsurvives into the app. Morning is deliberately unchanged: its API has a singlefailureUrlused for both legs, so pointing it at the failed URL would mislabel a walk-away. -
Abandoned checkout release — no webhook of any kind ever arrives (the buyer just closed the tab): four callers converge on the same money-safety guard (
PaymentMonitoringService.voidPendingSubscription/voidPendingTransaction— no settled transaction, no in-flight charge) to flippending → cancelled, stampedcancellationReason='abandoned_checkout'so a late payment still revives it (webhook-processing.service.ts’srevivingAbandonedCheckout):sweepAbandonedCheckoutscron (payment-monitoring.service.ts), on whichever fuse applies:- 24h idle normally. Idle is measured from the newest OPEN attempt (
payment_transactionsstillpending), falling back tosubscriptions.created_at— anchoring on the subscription alone would sweep a two-day-old row the member had just re-opened viaresume-checkout, cancelling it while they looked at the payment form. - 1h idle when the provider’s last word was a decline (a
failedtransaction and nothing open). The 24h wait exists to protect a member who might still be on the payment page; a refusal is the provider saying they are not, and making a capped presale seat wait out a day for a bank’s decision protects nobody. Afailedrow followed by a newerpendingone is a member on their second card — the long fuse applies again. - Candidates are ordered oldest-first so the 200-row batch cannot fill with young rows and starve genuinely abandoned ones.
detectStuckPendingstill sees declined rows but emits themoutcome: 'ignored'so they do not page — they are pending on purpose now;
- 24h idle normally. Idle is measured from the newest OPEN attempt (
- owner/admin manual dismiss,
POST /payments/:txnId/void(payment-monitoring.service.ts,voidPendingTransaction); - owner/admin dismiss from the member page,
POST /organizations/:orgId/subscriptions/:id/void-pending— the same thing keyed by subscription id, because that screen renders subscriptions and a retried checkout has several transaction rows; - the member themselves,
POST /subscriptions/my/:id/cancel-pending(subscriptions-plans/behavior.md,memberCancelPending) — unflagged; thesubscription-member-cancel-pendinggate 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 stayabandoned_checkoutfor revival to work.
Checkout decision: the member came back without paying
The gap this closes: nothing happened server-side when a member closed the hosted form. They were left holding a “complete payment” membership with no button that worked, a pending transaction, and (on a capped presale) a held seat. The 24h sweep was the only exit.
| Step | Endpoint | Effect |
|---|---|---|
| Report the landing | POST …/subscriptions/my/:id/checkout-return | Telemetry only, mutates nothing. Emits payment.checkout_cancel_returned + payment.return_landed. The provider fires no event for a form nobody submitted, so without this every drop-off looked like a webhook outage. |
| Resume | POST …/subscriptions/my/:id/resume-checkout | Fresh hosted page for the SAME pending row, via PlansService.resumeCheckout → purchase(resumeSubscriptionId). Re-runs the plan-availability, seat, compliance-form and intro-pricing gates by construction. Emits payment.checkout_resumed. |
| Roll back | POST …/subscriptions/my/:id/cancel-pending with { intent: 'regret', source } | Same guard, same abandoned_checkout reason, plus cancellation_requested_by = member. Row hidden from GET …/subscriptions/my, kept for staff. The decision is recorded on the abandoned charge attempt’s payment_transactions.metadata.checkoutCancel (subscriptions has no metadata column, and the two candidate columns already mean other things). |
memberActions on every member subscription response lists exactly what is on offer (['complete_checkout','cancel_pending'] for a live checkout), so no client re-derives it. The legacy singular memberAction is unchanged for deployed mobile builds.
- Renewal failure counter lives at
subscriptions.failed_charge_attempts. Next retry chosen fromRETRY_INTERVAL_DAYS = [3, 7, 14](recurring-charge.service.ts:21). - On the 3rd consecutive failure the sub flips to
debt,debtAmountInCentsis incremented by the plan price,debtSincestamped,nextChargeDate = null(recurring-charge.service.ts:246). The org owner must then trigger aclearDebtflow (debt.service.ts). - Membership
payment_statusmirrors the sub:current | past_due | debt. Used by bookings to refuse class entry.
Charge flow (hosted page)
PlansController.purchase→PlansService.purchase(apps/api/src/plans/plans.service.ts:200).- Resolves/creates a
pendingsubscription. Reuses pending row on retry (no duplicate inserts). - Calls
PaymentService.createHostedPayment(apps/api/src/payments/services/payment.service.ts:92):- Resolves the active provider config (decrypts credentials).
- Calls
adapter.createPaymentPage(...). - Upserts a pending
payment_transactionsrow keyed bysubscriptionId(payment-transaction.service.ts:154) — never duplicates on retry.
- Returns
{ paymentPageUrl, processId }. The client hard-redirects. - Provider POSTs the webhook →
payment-webhook.controller.tsvalidates signature →WebhookProcessingService.processWebhookEvent(webhook-processing.service.ts:42):- Looks up the pending txn by
providerTransactionId; if absent, falls back tocompletePendingBySubscriptionId. - Activates the sub inside a DB transaction: status
active, advancescurrentPeriodStart/End, refillsremainingCredits. - Stamps the card token to
member_payment_methods(replace existing, mark old inactive —replacePaymentMethod).
- Looks up the pending txn by
- On user return to
successUrl, the web app callsPOST /verify-return(payment.controller.ts:42). Idempotent fallback: re-fetches Cardcom’sGetLpResultand replays the same webhook handler.
Native-app return URLs go through a bridge. Providers reject custom-scheme return URLs (Morning fails the whole /payments/form call with 1103 כתובת אתר לא תקינה), so the mobile app must not send taikan://… as successUrl/cancelUrl. Instead it sends https://<api>/payments/app-return?to=<app-path>&… (app-return.controller.ts, @Public), which 302s to taikan://<app-path> forwarding every other query param — including sub=, appended later by payment.service.ts:132 / morning.provider.ts:448. The taikan:// redirect is what closes the app’s openAuthSessionAsync browser session. to is restricted to relative in-app path segments, so the endpoint can’t be used as an open redirect. Applies to plan purchase and card registration (both build the bridge URL via paymentReturnUrl in the mobile repo’s src/lib/api.ts).
Recurring charge flow
Cron 0 2 * * * in RecurringChargeService.handleRecurringCharges (recurring-charge.service.ts). See subscriptions-plans/behavior.md’s Renewal cron section for the full scheduled-plan-change boundary-swap detail (A2/A3/A4/A8); summarized here:
- A5 advisory lock (FIT-254 review Wave A): the whole sweep first attempts
pg_try_advisory_lockon a fixed key; if another run already holds it, this run logs a warning and returns immediately — no SELECT, no charges.FOR UPDATE SKIP LOCKEDalone only protects individual rows for the duration of their own implicit transaction, not the whole method, so two overlapping invocations (scheduled tick vs. the test-only manual-trigger endpoint) could otherwise both select and charge the same due subscriptions. SELECT … FOR UPDATE SKIP LOCKEDonsubscriptions WHERE (next_charge_date <= now OR (scheduled_plan_id IS NOT NULL AND next_charge_date IS NULL AND current_period_end <= now)) AND status IN ('active','past_due') AND payment_method_id IS NOT NULL OR scheduled_plan_id IS NOT NULL(recurring-charge.service.ts). A2(b) fix: the boundary OR-arm is what makes a comped (card-less) sub’s schedule reachable at all — such a sub’snext_charge_datestaysNULLforever (it’s never billed), so the originalnext_charge_date <= nowpredicate alone could never select it once its schedule fell due.- Per row: if
scheduled_plan_idis set, swap the plan first (see the boundary-swap section linked above) — A2(a): a card-less sub after the swap is billed via NEITHERgetActivePaymentMethodNOR any charge attempt (it’s a swap-only visit that advances its own period in the swap transaction); a carded sub proceeds as below. - Insert
pendingtxn first (the “record before charge” invariant), thenadapter.createCharge(token, …), then update txn status. - Success →
handleChargeSuccess: advance period, reset counters, set membershippayment_status = current. A3 exception: a row that went through the boundary swap this tick does NOT getremainingCreditsrefilled to the raw allotment — the swap’s in-place sweep already set it to (allotment − kept-booking deductions); refilling would erase that. - Failure →
handleChargeFailure: bump attempts, setnextChargeDatefromRETRY_INTERVAL_DAYS, firepayment-failedordebt-warningemail.
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:
| Gate | Where | Meaning |
|---|---|---|
adapter.getRefundCapability() === 'automatic' | in-memory | The adapter declares a verified refund API. No provider does this yet. |
isManagedTerminal(orgId) | one DB read | Taikan opened this terminal under its own dealer agreement (FIT-286), so the refund is ours to make. |
payment-automatic-refunds flag | PostHog, per-org | Per-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:
- Call
adapter.refund(...). - On success: original txn →
refunded, insert a newrefundtype txn linked to the original viametadata. - Emit
payment.refund_completedobservability event.
Manual capability (Cardcom, iCredit, Meshulam, Morning, Tranzila)
payment.service.ts:501. Two phases:
- Open: refuses if
refundTaskIdalready set (payment.service.ts:508). Creates atasksrow withtype='manual_refund', priority='high'and a 3-day due date. Updates the original txn tostatus='refund_pending', refundTaskId=task.id. - Close:
POST /organizations/:orgId/payments/refund-tasks/:taskId/completebody{ externalReference }. Inside a DB transaction (payment.service.ts:588):- Task →
completedwithcompletedByIdand timestamp. - Linked txn →
refunded, storesrefundExternalReference(credit-doc number). - Idempotent: if the task is already
completedthe call returns{ alreadyCompleted: true }without mutating.
- Task →
- Notification:
CancellationNotificationsService.refundCompletedto 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:
| Form | Used by | Why |
|---|---|---|
POST /webhooks/payments/:provider/:orgId | Cardcom, iCredit, Meshulam, Tranzila | Provider accepts per-payment notify URL; we encode orgId in the path. |
POST /webhooks/payments/:provider?org=… | Morning | Provider supports only ONE statically-registered webhook per business; owner pastes the URL once. |
Signature verification
Per-provider, in validateWebhookSignature. See README — Providers for the table. The dispatch is 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(orcourseEntitlementId) must match a real row; cross-org abuse is blocked explicitly for course entitlements atwebhook-processing.service.ts:215and:362. - For Cardcom we re-fetch the truth from
GetLpResultbefore mutating (platform-billing-webhook.controller.ts:73; the per-org payments controller relies on the IPN body butverify-returnre-fetches).
Idempotency
| Mechanism | Where |
|---|---|
txnByProvider.status === 'completed' short-circuit | webhook-processing.service.ts:121 |
| State-machine guard “refuse anything not pending→terminal” | platform-billing.service.ts:313 |
Refund task completed → alreadyCompleted: true no-op | payment.service.ts:599 |
Unique-by-provider-id index on platform_billing_transactions | libs/db/src/lib/schema/platform-billing.ts:120 |
Course entitlement upsert by (user_id, program_id) | libs/db/src/lib/schema/courses.ts:145 |
Course completion upsert by (entitlement_id, course_workout_id) | libs/db/src/lib/schema/courses.ts:204 |
The per-org payment_transactions table has no unique index on (org_id, provider_transaction_id). Concurrent webhook + verify-return for the same processId can both run the success path; the second one finds status='completed' and bails (webhook-processing.service.ts:121), but the period-advance update in the trailing transaction can still race. See FIT-133.
Partial-state contingencies
| Scenario | What happens | Recovery |
|---|---|---|
| Provider returns success but DB write throws after adapter call | Adapter call already happened (card debited). Pending row stays pending. Next webhook arrival OR next cron tick reconciles. | Cron verify-return route or manual replay of the webhook. |
| DB row inserts but provider call times out | We insert pending before the adapter call (recurring-charge.service.ts:122). If the provider actually succeeded but we never got the response, the next webhook resolves it; if it failed silently, the pending row stays open. | Reconciler (platform-billing has one; per-org payments lacks one — gap). |
| Webhook never arrives | verify-return POST from the success page replays the same processing logic. | Member or owner can hit /verify-return again. |
Multiple webhooks for the same processId | Short-circuit on completed status. | None needed. |
| Refund task closed twice | Service guard returns alreadyCompleted: true. | None. |
Refund task externalReference is wrong | No verification — we trust the gym owner. | Edit task description, no automated repair. |
| Member closes the hosted form / the card is declined | No provider event exists. The subscription stays pending and the member is offered the decision (resume or roll back) on the return page. | Member resumes, rolls back, or the 24h sweep releases the row. payment.checkout_cancel_returned is the only trace either way. |
| Member resumes, but the plan sold out / retired / grew a signing requirement meanwhile | resumeCheckout delegates to purchase, so every gate re-runs — the member gets the same refusal a fresh purchase would, not a payment page for something they can’t have. | Buy a different plan; sign the form and retry. |
| Member rolls back a checkout that a late payment then completes | The row is cancelled + abandoned_checkout, which revivingAbandonedCheckout still recognises — the subscription is revived and becomes visible again. | None. This is why the rollback must not invent its own reason string. |
| Presale withdrawn while the opening-day sweep is promoting it | The withdrawal re-checks status = 'scheduled' under FOR UPDATE; if the sweep won, it 409s rather than closing a membership that just started. | Reload; the member now has a live membership and the cancellation flow. |
Side effects
| Action | Side effect |
|---|---|
| Charge succeeded | sendPaymentReceipt email (payment.service.ts:783), membership payment_status = current, subscription.remainingCredits refilled, 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 closed | Member email cancellationNotifications.refundCompleted (payment.service.ts:656), observability payment.refund_completed. |
| Cancellation request approved | Sub cancelled, refund issued via the capability-aware path, member email cancellationRequestApproved (cancellation-requests.service.ts:320). |
| Cancellation scheduled at period end | Member email cancellationScheduled, cancellation_review task created (priority=low). |
Presale withdrawn (scheduled → cancelled) | 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 member | payment.checkout_abandoned with reason: 'dismissed_by_member' plus intent/source, audit row (mode: 'member_pending_cancel'), checkoutCancel stamped on the abandoned transaction’s metadata. |
| Join-link checkout released unpaid | payment.checkout_abandoned carrying cause: 'declined' | 'abandoned', payment.join_membership_released, a CRM lead, and — 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.
Recovering an unpaid join-link checkout
A registrant who reaches a payment page and does not pay ends one of two ways, and they are opposite events that used to be recorded as one.
What a Cardcom payment.failed actually is
Measured over 30 days of production (33 webhooks, two terminals): every
payment.failed Cardcom sent arrived 20–30 minutes after LowProfile/Create,
on Cardcom’s own ~10-minute sweep grid, with an empty TranzactionInfo and no
TranzactionId. Not one within the window a person could have submitted a
card. That is the payment page expiring with nobody on it — a walk-away,
not a refusal.
A refused card, on these terminals, produces no webhook at all: Cardcom
shows its own /EA/Error page (“אירעה שגיאה לא צפויה / החיוב נכשל”) and calls
neither WebHookUrl nor FailedRedirectUrl (observed 2026-09-03, page
aeb2e9b1-…). The terminals also run with CVV/ID checks removed (the Israeli
recurring-terminal requirement), so a wrong CVV or a wrong-but-future expiry is
approved by the issuer — declines cannot be manufactured from a good card.
So CardcomProvider.mapDealToEvent classifies a failure with no
TranzactionId as failureKind: 'checkout_expired', and
WebhookProcessingService.resolveExpiredCheckout:
- resolves the attempt by page id (
metadata.lowProfileIdagainst the pending row’sproviderTransactionId, whichPaymentServiceset to Cardcom’sLowProfileIdat creation), so an old page expiring late never closes a newer one the member is typing into; - only for a still-
pendingsubscription, org-scoped through the membership; - marks the attempt
cancelled(the same marker the supersede path uses), neverfailed; - emits
payment.webhook_ignoredwithreason: 'checkout_page_expired'and stops — noactivation_failed, no Sentry, subscription leftpendingfor the release sweep to decide on.
A failed attempt therefore means a refused card and nothing else. That is
what wasDeclined(), the CRM note, the stuck-pending alert and the recovery
template all read.
Recording a genuine decline
If Cardcom ever reports a refusal it will carry the deal it refused — a
TranzactionId and an acquirer ResponseCode — and takes the id-keyed path.
That path looks the deal id up first and then the page id
(lowProfileId): the pending row only learns the deal id on success, so
without the second lookup every real decline escalated as
transaction_not_on_file and opened a reconciliation task. The attempt is
then marked failed carrying the gateway’s own errorMessage and raw body,
and payment.activation_failed gains declineCode / errorMessage alongside
our own reason; a Payment declined: <kind> warning goes to Sentry,
fingerprinted on the cause.
Verification. Cardcom signs nothing, so every Cardcom webhook goes through
verifyWebhookOutOfBand. For the expiry shapeGetLpResultanswers a non-zeroResponseCode, which reads asdeal_not_found— so today the expiry only reaches processing through the unverified body, i.e. whilepayment-webhook-signature-enforcementis off for the org (the default). Under enforcement it is rejected, and the page’s attempt stayspendinguntil 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:
| Reader | Expired page (cancelled) | Refused card (failed) |
|---|---|---|
detectStuckPending | outcome: 'ignored', reason: 'checkout page expired — awaiting release sweep'. A genuinely stuck checkout (page still pending, no word from the provider) still emits failure. | outcome: 'ignored', reason: 'declined charge — member may retry'. |
| Join-link release sweep | cause: 'abandoned', reason: 'join link, unpaid > 30min'. | cause: 'declined', reason: 'join link, card declined'. |
| CRM lead note | ”never completed payment”. | Says the card was declined and tells the coach to offer another payment method. |
The recovery mail
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 pending →
cancelled under FOR UPDATE, so a second sweep pass never reaches it. Every
send is best-effort and swallowed — the membership is already released and the
audit row already written, and a throw would abandon the rest of the batch.
Seeing it
- PostHog —
payment.checkout_abandoned(cause),payment.recovery_email_sent/_failed, andlead_created, which is new: the CRM demotion previously wrote a lead row and emitted nothing, so “the seat was freed” was measurable and “the person was kept” was not. - Sentry — declines are captured as a
warning, fingerprinted on[decline kind, decline code], so it is one issue per cause whose count is the signal, never one per member. A recovery mail that fails to send is captured as an exception: that is a lead lost silently, which is the failure this whole path exists to end. - The dead end, closed. A released registrant who signs in used to get “your membership is currently inactive / please contact your organization admin” and a sign-out button — for someone whose card was refused forty minutes earlier, and whose gym mostly never hears about it.
/users/menow returnsrejoinToken(andrejoinPlanId, the released checkout’s plan, so the button reopens that offer) on a membership that can be redeemed back, andRoleRouterturns that screen into an action. The API owns eligibility:cancelledandjoin_linkonly, only when the person never committed to a subscription (isUncommittedRegistrant— staff removal writes the samecancelled, and a member who paid for a year and was removed must not be handed a “finish signing up” button), and only when the org still has joining enabled and a live link. A suspended member never gets one —acceptJoinLinkrefuses a suspended membership, so the button would fail.resolveRejoinTokenis shared with the email’s button so the two offers cannot drift apart. - One person, one profile — the API keys events on the Taikan user id. The web SDK now identifies with the same id (read from
/users/me,apps/web/src/providers.tsx), so no merge is needed. Theuser.createdtracking.alias(user.id, clerkId)stays for the mobile app, which still identifies with the Clerk id: the alias folds that profile in when the client identify comes after it (observed working, 7 min later), but loses the race at web sign-up where the browser’s$identifylands first and PostHog then refuses to merge an identified person — which is why the web side switched keys rather than relying on it.
Permissions
| Action | Route | Required role |
|---|---|---|
| Configure provider | POST /organizations/:orgId/payment-config | owner or admin (payment-provider-config.controller.ts:29) |
| Refund a transaction | POST /organizations/:orgId/payments/:txnId/refund | owner or admin (payment.controller.ts:29) |
| Close manual_refund task | POST /organizations/:orgId/payments/refund-tasks/:taskId/complete | owner or admin |
| List org transactions | GET /organizations/:orgId/payments | owner or admin |
| List my transactions | GET /organizations/:orgId/payments/my | any active member |
| Approve cancellation request | POST /organizations/:orgId/cancellation-requests/:id/approve | owner or admin (cancellation-requests.service.ts:362) |
| Cancel subscription (admin) | POST /organizations/:orgId/subscriptions/:id/cancel | owner or admin (subscriptions.service.ts:302) |
| Register card for a member | POST /organizations/:orgId/members/:id/register-card | owner or admin (card-registration.controller.ts:44) |
| Change platform tier | PATCH /organizations/:orgId/tier | owner only (platform-tiers.controller.ts:64) |
| List member payment methods | GET /organizations/:orgId/members/:membershipId/payment-methods | owner or admin, payments/view |
| Manual charge (desk charge) | POST /organizations/:orgId/members/:membershipId/charge | owner or admin, payments/manage, flag admin-card-on-file (manual-charge.controller.ts:13) |
| Clear debt | POST /organizations/:orgId/subscriptions/:subscriptionId/clear-debt | owner 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: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):
- PostHog flag
admin-card-on-file, evaluated per-org (organizationgroup). Fail-closed: only a literaltruepasses; unset/unreachable/false→ 403, before any DB/provider work. - Permission
payments/manage(owner/admin). - Membership exists in org (cross-org → 404) and
status === 'active'— otherwise 409MEMBERSHIP_NOT_ACTIVE. - A provider config exists for the org — otherwise 400
"No payment provider configured for this organization"(BadRequestException, not a structuredPaymentErrorCodesentry — the existing idiomcard-registration.controller.tsalso uses). Note: the FIT-254 spec’s §3.3 table says this should be 409no_payment_provider; the shipped code throws plain 400. Documented here as the actual behavior — see the ADR for the discrepancy. - Provider is in
CHARGE_VERIFIED_PROVIDERS(manual-charge.service.ts:34:['cardcom', 'meshulam', 'test']) — otherwise 409PROVIDER_CHARGE_UNVERIFIED. - Active payment method exists on the membership — otherwise 409
NO_ACTIVE_PAYMENT_METHOD.
Mechanics — pending-row-first, identical discipline to recurring-charge.service.ts: insert a payment_transactions row (type='charge', status='pending', metadata: { source: 'manual_charge', chargedByUserId, description }) before calling adapter.createCharge; update the same row to completed/failed afterward, never delete it — a crash between the adapter call and the update leaves a pending row for reconciliation, never a charged-card-with-no-record. An adapter throw (network/timeout) is caught and treated as a failed charge, same row.
No idempotency key — each request is a distinct charge attempt; a double-click or retry-after-timeout produces two separate transactions. The confirm dialog (client-side) is the only guard (documented gap, matches PD-A3’s “confirm step” design).
Observability: payment.manual_charge — amount, orgId, membershipId, actor, provider, outcome. The description free-text field is deliberately never sent to PostHog (PII risk) — it lives only in the DB row and the receipt email.
Does NOT emit SUBSCRIPTION_RENEWED — that event drives membership_renewed automations; a desk charge is not a renewal.
Receipt: on success, PaymentService.sendPaymentReceipt fires with the description as an extra line item (payment-receipt.ts gained an optional description field, rendered as Description: <text> when present).
Refund: unchanged — the existing capability-aware refund path (see Refund flows) applies to manual-charge transactions with no special-casing.
Member visibility: appears automatically in GET payments/my and the profile payment history as a charge-type transaction.
Debt collection — FIT-254 §3.4
POST /organizations/:orgId/subscriptions/:subscriptionId/clear-debt (manual-charge.controller.ts → ManualChargeService.clearDebt). Same guard stack as manual charge (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:
- Charge-before-record → pending-row-first. The
payment_transactionsrow is now inserted BEFOREadapter.createCharge(mirroringmanual-charge.service.ts’s discipline) and updated after, regardless of outcome — a charge that succeeds at the provider but fails to persist locally is still on record. - Non-idempotent under concurrency → atomic post-charge claim. Holding a DB row lock across the provider round-trip is unacceptable latency-wise, so instead: a cheap, non-locking re-read guard runs immediately before the charge (catches the common “already cleared” case without a wasted charge), and the REAL guard is an atomic claim AFTER the charge succeeds —
UPDATE subscriptions SET status='active', debt_amount_in_cents=0, ... WHERE id=... AND status='debt' AND debt_amount_in_cents = <the amount just charged> RETURNING. If two concurrentclearDebtcalls both pass the pre-charge checks and both charge the provider, only the first atomic claim wins; the second’s charge is a genuine double-collection. That case is not thrown as an error to the caller (the debt genuinely is cleared, by the other request) — it’s flagged viaPaymentService.flagTransactionForManualReview(a thin wrapper around the existingmanual_refundtask-open helper), which flips that transaction torefund_pendingand opens amanual_refundtask so an owner reconciles the double charge. - Never bills again →
nextChargeDaterestored. On success,nextChargeDateis set tomax(now, currentPeriodEnd)andfailedChargeAttemptsreset to 0 — previouslynextChargeDatestayedNULLforever 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.