Payments
Provider integrations, transaction lifecycle, refunds, and webhook dispatch for member-facing money flow inside a gym.
What & why
Payments is the B2B2C money rail: a member pays the gym, the gym keeps the cash, Taikan only collects platform fees on the side (see platform-billing/). Every charge runs through a per-org payment_provider_configs row holding encrypted credentials for the org’s chosen gateway. Multiple Israeli acquirers are supported in parallel; the org picks one.
Persona impact:
| Persona | Surface |
|---|---|
| Member | Buy a plan, register a card, see receipts (/dashboard/plans, /buy/courses/[id]). Cancellation + refund request UI. |
| Owner / Admin | Configure provider, issue refunds, close manual-refund tasks, review cancellation requests, see analytics (/dashboard/payments). |
| Platform (Taikan) | Observability only — money never lands in a Taikan account on this rail. |
Capabilities
- Hosted-page checkout (Cardcom LowProfile, iCredit, Meshulam, Tranzila, Morning) — see Providers.
- Tokenised recurring charges driven by Taikan (no native recurring deals on Cardcom).
- Refunds split by capability:
automatic(single API call) vsmanual(opens amanual_refundtask the owner closes with the credit-doc number). - Verify-on-return fallback when a provider webhook never arrives (
POST /organizations/:orgId/payments/verify-return). - Tax-document linkage via
payment_provider_clients(Morning) +invoicing_configs(GreenInvoice plug-in scaffold). - Card-on-file registration by an admin on behalf of a member (
POST /members/:id/register-card). - Manual (desk) charges and debt collection on a member’s saved card, flag
admin-card-on-file— see Manual charges (staff) and Debt collection. Full flag table (all four FIT-254 flags):subscriptions-plans/README.md. - Offline payments — recording money that reached the gym with no gateway involved (cash, bank transfer, cheque, Bit) and issuing its tax document standalone. Unflagged since 2026-08-22 (
manual-paymentsmerged permanently ON).POST /organizations/:orgId/members/:membershipId/payments/record. - Payment links — a gateway-hosted page minted for one member and one amount, for collecting from someone with no card on file. Unflagged since 2026-08-22 (
payment-linksmerged permanently ON). Delivery is copy/email today; WhatsApp is blocked on Meta Business Verification. - Ad-hoc discounts — a per-member negotiated price stored on the subscription and honoured by every renewal. Unflagged since 2026-08-22 (
purchase-discountsmerged permanently ON).
Money movement vs document issuance
Until 2026-08, every tax document Taikan produced was a field on a charge: Morning’s type on /payments/form, Cardcom’s Document block inside LowProfile/Create. That works exactly as long as money moves through the gateway and not one step further, which is why a member paying cash could not be given a receipt.
PaymentProviderAdapter.issueDocument() separates the two. It is the only path that produces a document without moving money, and it is what makes offline payments legal to accept.
| Morning | Cardcom | |
|---|---|---|
| Endpoint | POST /documents | POST /api/v11/Documents/CreateDocument |
| Cash | payment[].type: 1 | Cash: <scalar> — not an array |
| Cheque | type: 2 + bankName (a NAME) | Cheques[] + BankNumber/SnifNumber (integers) |
| Bank transfer | type: 4 | CustomFields[] carrying asmacta |
| Bit | type: 10 + appType: 1 | CustomFields[] |
| Credit document | negative price | positive amount + a refund document type |
The two providers disagree on almost everything, including the sign convention: Cardcom rejects a negative amount outright with 9998 while Morning requires one. Cardcom also disagrees with itself — CreateDocument answers with the string "Receipt" where GetReport reports the same document as numeric InvoiceType: 1, which is why payment_transactions.document_type is varchar and never coerced.
Cardcom’s CustomFields is the load-bearing discovery: its published description is a copy-paste of “Array of cheques”, but it carries asmacta (a transfer reference) plus a Sum that satisfies the 6311 balance check. Without it, Cardcom orgs could not document a transfer at all. Verified against a live terminal on 2026-08-19 (pnpm cardcom:doc-probe).
Sale vs receipt. Selling a membership for cash is a sale, so the default document is tax_invoice_receipt (חשבונית מס קבלה), matching what the card path already issues. Asking Cardcom for a plain Receipt filed the document as type 3, קבלה מלכ”ר / פטור מע”מ — a VAT-exempt receipt, which states something untrue about a VAT-registered gym.
Issuance is best-effort and never fails the payment. Money has already changed hands by the time the document is attempted; refusing to record it because a PDF could not be produced loses the more important fact. A failure writes payment_transactions.document_error, which with its partial index is the “receipts we still owe someone” queue.
Providers
Discovered from apps/api/src/payments/providers/. Registered in PaymentsModule.onModuleInit.
| Provider | Adapter | Webhook URL form | Signature validation | Refund capability | Notes |
|---|---|---|---|---|---|
cardcom | cardcom.provider.ts | path /webhooks/payments/cardcom/:orgId | none — Cardcom does not sign; trust comes from re-fetching GetLpResult server-side | manual | Drives platform-billing too. |
icredit | icredit.provider.ts | path /webhooks/payments/icredit/:orgId | GroupPrivateToken body field equals stored credential | manual | Rivhit-backed credit document. |
meshulam | meshulam.provider.ts | path /webhooks/payments/meshulam/:orgId | webhookKey body field equals stored apiKey | manual | Light-API iframe tokenisation. |
morning | morning.provider.ts | query /webhooks/payments/morning?org=… (single statically-registered URL per business) | TBD — temporary accept; controller dumps headers for debugging | manual | Auto-issues חשבונית מס/קבלה. |
tranzila | tranzila.provider.ts | path /webhooks/payments/tranzila/:orgId | stub — always valid (HMAC SHA256 TODO) | manual | Adapter is mostly a stub; not production-validated. |
test | (enum value only, no adapter) | n/a | n/a | n/a | Reserved for stubbed e2e seeding. |
All providers are manual for refunds today. The framework distinguishes automatic | manual (apps/api/src/payments/services/payment.service.ts:411); flipping a provider once we’ve verified its refund API end-to-end is a single return-value change.
Related features
subscriptions-plans/— owns the subscription state machine and cancellation requests. Payments fires the lifecycle transitions via webhook handlers.platform-billing/— separate money rail for Taikan’s own monthly fee. Uses the sameCardcomProvideradapter but a different DB schema and a single shared terminal (PLATFORM_BILLING_*env).courses/— course checkouts share the hosted-payment plumbing; the webhook activates acourse_entitlementsrow instead of a subscription.platform-tiers/— theautomated_billingfeature is gated to tierpro; the@RequiresFeatureguard fronts plan CRUD.webhooks/— the Clerk webhook lives here; payment provider webhooks live inapps/api/src/payments/controllers/payment-webhook.controller.ts(registered underPaymentsModule, notWebhooksModule).
Status
Offline payments / payment links / discounts (2026-08-20): built end-to-end on the API, migration 0117, all three flags created OFF. Standalone document issuance is verified against a live Cardcom terminal for both Cash and CustomFields; Morning’s path is implemented against the vendor’s OpenAPI but has not been exercised live. Web UI shipped behind the same flags. No e2e journey yet.
Production: Cardcom (live + platform billing terminal), iCredit, Meshulam.
Beta / debug: Morning (signature TBD — see morning.provider.ts:941), Tranzila (signature stub).
Refund automation: nothing in automatic yet — all providers open a manual task.
Taikan-managed terminals (FIT-286): unflagged since 2026-08-22 (taikan-managed-payments merged permanently ON — the mode choice and the provision endpoint are available to every org). Built end-to-end (settings UI → NewCompany → pending_kyc gate → GetCompanyStatus check → activation email), never yet run against Cardcom — see gaps below. The hourly status sweep was removed 2026-08-13 and restored 2026-08-16 behind a second flag, managed-terminal-kyc-poll (per-org, fail-closed, 0% everywhere): it now sends CompanyNumber rather than CompanyInternalID, which is what it was getting wrong. Until that flag is turned on for an org, activation there still needs a human to press Check status or a platform admin to settle it.
Gaps
- FIT-133 — webhook idempotency hardening: current logic short-circuits on
status === 'completed'per row, but no row-level advisory lock; concurrent webhook + verify-return can race on the period-advance update (webhook-processing.service.ts:257). - FIT-134 — capability flip: validate Cardcom
RefundDealso its capability can move from'manual'→'automatic'. - FIT-136 — failed renewal retry tuning:
RECURRING_INTERVAL_DAYS = [3, 7, 14]is hardcoded inrecurring-charge.service.ts:21; needs per-org override and a max-retries CTA path. - Morning webhook signature —
morning.provider.ts:941returnstrueregardless. Header capture is in place (payment-webhook.controller.ts:101); finalise the scheme before scaling Morning usage. - Tranzila adapter — signature + invoicing methods are stubs (
tranzila.provider.ts:135,tranzila.provider.ts:155). Treat as alpha. - FIT-286 never run against Cardcom — the dealer credentials (
CARDCOM_SUPPLIER_USERNAME/PASSWORD/SECRET) and the module codes arrived on 2026-08-12, so the path is now exercisable, but no application has been submitted yet.CARDCOM_AGREEMENT_IDis optional per Cardcom and omitted from the request when unset.resolveSupplierstill throwsServiceUnavailablenaming any missing required value rather than half-opening a terminal. - FIT-286 first call hits production —
CARDCOM_BASE_URLdefaults tosecure.cardcom.solutions, andNewCompanyopens a real company with a real monthly fee that nothing deduplicates. Confirm with the dealer contact whether a sandbox host exists before the first submission. - FIT-286 address code parity — unverified — the terminal application now sends codes picked from the national registry (data.gov.il, רשות האוכלוסין), not typed by the owner. Cardcom said (2026-08-11) the codes are Israel Post’s. The two are widely treated as the same scheme, but nothing public proves it, and Israel Post sells its own file. One real application with a known address settles it — if Cardcom rejects the address, ask Ron for Cardcom’s own code table.
- FIT-286 modules are open-once — the codes are
FITKITPAYMENT-SLIKA(סליקה אינטרנטי),FITKITPAYMENTS-DOCS(מסמכים),FITKITPAYMENTS-TOKENS(אסימונים) andFITKITPAYMENTS-HAKAMA(הקמה), issued to Taikan via Ron on 2026-08-12; the prefix really is inconsistent. Adding a module to an existing terminal is manual, done by the Cardcom rep — there is no endpoint. A terminal opened without TOKENS therefore costs a phone call per gym, and the mismatch warning at provisioning time is the only automated signal we get. - FIT-286 unanswered by Cardcom — whether
ModulesListreflects the modules actually opened (we warn on a mismatch either way); whetherNewCompanyis idempotent per ח.פ or opens a second company with a second monthly fee (Taikan guards its own side with aConflicton a pending application, which does not cover a timed-out request whose company was created); and the fullResponseCodelist — Almog is compiling one, currently Hebrew descriptions enumerated from 0. - FIT-286 status lookup key — corrected, still unverified against Cardcom — the status call was sending
CompanyInternalID(a UUID) ascompanyNumberand dying in model binding every time (FITKIT-BACKEND-3V,-3W). The right value is a different field on the sameNewCompanyresponse:CompanyNumber, “מספר פנימי של בית העסק” — Cardcom’s own example pairsCompanyInternalID: 21348withCompanyNumber: 21351. That is now what is stored and sent, and the sweep is back behindmanaged-terminal-kyc-poll. Nobody has yet seenGetCompanyStatusanswerResponseCode: 0, so treat the fix as unconfirmed until the first flagged org gets a clean response; roll the flag out one org at a time and watch[CARDCOM][GetCompanyStatus]. A terminal provisioned before this (or by a Cardcom response carrying noCompanyNumber) has no key stored at all — the sweep skips it by design and it stays on the Check status button. - FIT-286 rejections are invisible — approval is detectable (
IsDone), a decline is not: it simply never arrives. Closing a rejected application still needs a human on the platform-adminmanaged-statusendpoint. - Cardcom webhook verification —
validateWebhookSignaturereturnsfalseunconditionally (this doc previously saidtrue; the code has saidfalsesince the out-of-band path landed). That is deliberate: Cardcom signs nothing, so every webhook is routed toverifyWebhookOutOfBand, which re-fetchesGetLpResultand replaces the untrusted body with the provider’s own facts. The remaining work is enablingpayment-webhook-signature-enforcementper org — it fails open today. - Manual-payment document rendering unverified — the API accepts
CustomFields/asmactaand returns a document, but nobody has opened the resulting PDF to confirm the payment line actually reads “העברה בנקאית” rather than an unlabelled custom entry. The API said yes; an accountant reads the PDF. - Cancelling a manual payment is not built — issuance has no inverse in the product yet.
issueDocument({kind: 'credit'})exists and both adapters implement it, but nothing calls it for an offline payment, so a mistyped cash receipt has to be credited by hand in the provider’s portal. - Payment-link expiry is Taikan-side only —
expireStaleflips the row, but nothing tells the gateway to stop honouring the page. A member who kept the URL could still pay an “expired” link; the webhook would record the money (deliberately — seesettlePaymentLink) against a link the UI calls dead. payment_links.process_idhas no uniqueness constraint — a plain index, wherepayment_txns_idempotency_key_uniqis what makes redelivery structurally safe for transactions.unique(provider, process_id)would make “one gateway deal ⇒ one link” an invariant rather than a convention.- Discounts have no expiry — a negotiated price persists until someone clears it. There is no “3 months at ₪200 then back to list”; that shape is what plan-level intro pricing is for.
See also docs/_archive/product/payments-prd.md (historical context, stale on the manual-refund task design) and docs/_archive/plans/cardcom-production-terminal.md (Cardcom rollout plan).