Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPaymentsPayments

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:

PersonaSurface
MemberBuy a plan, register a card, see receipts (/dashboard/plans, /buy/courses/[id]). Cancellation + refund request UI.
Owner / AdminConfigure 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) vs manual (opens a manual_refund task 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-payments merged 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-links merged 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-discounts merged 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.

MorningCardcom
EndpointPOST /documentsPOST /api/v11/Documents/CreateDocument
Cashpayment[].type: 1Cash: <scalar>not an array
Chequetype: 2 + bankName (a NAME)Cheques[] + BankNumber/SnifNumber (integers)
Bank transfertype: 4CustomFields[] carrying asmacta
Bittype: 10 + appType: 1CustomFields[]
Credit documentnegative pricepositive 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 itselfCreateDocument 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.

ProviderAdapterWebhook URL formSignature validationRefund capabilityNotes
cardcomcardcom.provider.tspath /webhooks/payments/cardcom/:orgIdnone — Cardcom does not sign; trust comes from re-fetching GetLpResult server-sidemanualDrives platform-billing too.
icrediticredit.provider.tspath /webhooks/payments/icredit/:orgIdGroupPrivateToken body field equals stored credentialmanualRivhit-backed credit document.
meshulammeshulam.provider.tspath /webhooks/payments/meshulam/:orgIdwebhookKey body field equals stored apiKeymanualLight-API iframe tokenisation.
morningmorning.provider.tsquery /webhooks/payments/morning?org=… (single statically-registered URL per business)TBD — temporary accept; controller dumps headers for debuggingmanualAuto-issues חשבונית מס/קבלה.
tranzilatranzila.provider.tspath /webhooks/payments/tranzila/:orgIdstub — always valid (HMAC SHA256 TODO)manualAdapter is mostly a stub; not production-validated.
test(enum value only, no adapter)n/an/an/aReserved 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.

  • 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 same CardcomProvider adapter but a different DB schema and a single shared terminal (PLATFORM_BILLING_* env).
  • courses/ — course checkouts share the hosted-payment plumbing; the webhook activates a course_entitlements row instead of a subscription.
  • platform-tiers/ — the automated_billing feature is gated to tier pro; the @RequiresFeature guard fronts plan CRUD.
  • webhooks/ — the Clerk webhook lives here; payment provider webhooks live in apps/api/src/payments/controllers/payment-webhook.controller.ts (registered under PaymentsModule, not WebhooksModule).

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 → NewCompanypending_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 RefundDeal so its capability can move from 'manual''automatic'.
  • FIT-136 — failed renewal retry tuning: RECURRING_INTERVAL_DAYS = [3, 7, 14] is hardcoded in recurring-charge.service.ts:21; needs per-org override and a max-retries CTA path.
  • Morning webhook signaturemorning.provider.ts:941 returns true regardless. 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_ID is optional per Cardcom and omitted from the request when unset. resolveSupplier still throws ServiceUnavailable naming any missing required value rather than half-opening a terminal.
  • FIT-286 first call hits productionCARDCOM_BASE_URL defaults to secure.cardcom.solutions, and NewCompany opens 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 (אסימונים) and FITKITPAYMENTS-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 ModulesList reflects the modules actually opened (we warn on a mismatch either way); whether NewCompany is idempotent per ח.פ or opens a second company with a second monthly fee (Taikan guards its own side with a Conflict on a pending application, which does not cover a timed-out request whose company was created); and the full ResponseCode list — 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) as companyNumber and dying in model binding every time (FITKIT-BACKEND-3V, -3W). The right value is a different field on the same NewCompany response: CompanyNumber, “מספר פנימי של בית העסק” — Cardcom’s own example pairs CompanyInternalID: 21348 with CompanyNumber: 21351. That is now what is stored and sent, and the sweep is back behind managed-terminal-kyc-poll. Nobody has yet seen GetCompanyStatus answer ResponseCode: 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 no CompanyNumber) 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-admin managed-status endpoint.
  • Cardcom webhook verificationvalidateWebhookSignature returns false unconditionally (this doc previously said true; the code has said false since the out-of-band path landed). That is deliberate: Cardcom signs nothing, so every webhook is routed to verifyWebhookOutOfBand, which re-fetches GetLpResult and replaces the untrusted body with the provider’s own facts. The remaining work is enabling payment-webhook-signature-enforcement per org — it fails open today.
  • Manual-payment document rendering unverified — the API accepts CustomFields/asmacta and 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 onlyexpireStale flips 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 — see settlePaymentLink) against a link the UI calls dead.
  • payment_links.process_id has no uniqueness constraint — a plain index, where payment_txns_idempotency_key_uniq is 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).