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

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)──► cancelled ├──(payment.failed, attempts<3)──► past_due │ │ │ (retry day 3/7/14 succeeds) │ │ ◄───────────────────────────────────────┘ ├──(attempts==3)──► debt ├──(admin freeze)──► paused └──(admin cancel | cancel-at-period-end cron)──► cancelled
  • First-purchase failure flips pending → cancelled immediately (webhook-processing.service.ts:379). No retry — the buyer must re-initiate checkout.
  • 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.

Recurring charge flow

Cron 0 2 * * * in RecurringChargeService.handleRecurringCharges (recurring-charge.service.ts:46).

  1. SELECT … FOR UPDATE SKIP LOCKED on subscriptions WHERE next_charge_date <= now AND status IN ('active','past_due') AND payment_method_id IS NOT NULLrecurring-charge.service.ts:59. Skip-locked prevents two workers double-charging.
  2. Per row: insert pending txn first (the “record before charge” invariant), then adapter.createCharge(token, …), then update txn status.
  3. Success → handleChargeSuccess: advance period, reset counters, set membership payment_status = current.
  4. 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) (payment.service.ts:397). Routes by adapter.getRefundCapability().

Automatic capability (no provider does this yet)

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.

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

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.

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)
Member self-cancel at period endPOST /organizations/:orgId/subscriptions/my/:id/cancel-at-period-endthe subscription’s owner (subscriptions.service.ts:486)
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)

Plan CRUD additionally requires the membership_plans feature on the org’s tier (plans.controller.ts:69).