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.
| 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)──► 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 → cancelledimmediately (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 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.
Recurring charge flow
Cron 0 2 * * * in RecurringChargeService.handleRecurringCharges (recurring-charge.service.ts:46).
SELECT … FOR UPDATE SKIP LOCKEDonsubscriptions WHERE next_charge_date <= now AND status IN ('active','past_due') AND payment_method_id IS NOT NULL—recurring-charge.service.ts:59. Skip-locked prevents two workers double-charging.- Per row: 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. - 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) (payment.service.ts:397). Routes by adapter.getRefundCapability().
Automatic capability (no provider does this yet)
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. |
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). |
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
| 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) |
| Member self-cancel at period end | POST /organizations/:orgId/subscriptions/my/:id/cancel-at-period-end | the subscription’s owner (subscriptions.service.ts:486) |
| 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) |
Plan CRUD additionally requires the membership_plans feature on the org’s tier (plans.controller.ts:69).