Inkress ADRsarchitecture decisions
Overview / Payments
ADR 0008

Payments run through a provider-agnostic adapter, chosen per merchant

AcceptedPayments

Context

Inkress serves merchants across markets that each need different payment processors — First Atlantic Commerce (FAC/PowerTranz) and WiPay for card in the Caribbean, Lynk for Jamaican wallet, Stripe and PayPal internationally, plus bank/ACH and Fygaro. A single hardcoded processor would not work, and scattering if provider == … branches through the order and checkout code would couple every money path to every processor.

Two things had to be decided: the shape of the processor abstraction, and how a processor is chosen for a given charge.

Order / checkoutreads merchantService.Payment.Providerbehaviourmodule = capitalize(adapter)FacWiPayLynkStripePayPalAchFygaroAnyper-merchant config
FigureA provider is resolved dynamically from the merchant’s configured adapter.

Decision

Model every processor as an interchangeable provider adapter behind a thin behaviour, and select the provider per merchant from configuration, resolved dynamically at charge time — never hardcoded and never inferred from currency or region.

  • The adapter contract. A processor is a module implementing Service.Payment.Provider (process/1, validate_webhook/1, validate_order/1), plus a set of shared entry points used by convention (e.g. create_payment_link/2, create_invoice/2). Live adapters: Fac, Lynk, Wipay, Stripe, Paypal, Ach, Fygaro, and an Any aggregator; CommercePayments bridges to the newer service (ADR 0009). (Square is a scaffold, Atlantic is dead code.)
  • Data-driven selection. Each merchant configures one or more payment methods; the chosen method carries an adapter string, and the calling code resolves the module by name — Service.Payment.Provider.<Adapter> — rather than switching on a fixed list. A merchant's first active method is used; a merchant with multiple methods is routed to the Any aggregator, which presents the alternatives. Adding a processor is: add a module + enable it as a merchant method.
  • Per-merchant credentials, secrets kept server-side. A method's credentials are name/value rows (payment_provider_values, with method-level overrides), and the secret value is excluded from serialization so it never leaves the server. Shared endpoints and platform keys come from the environment, not from merchant rows.

Consequences

  • Positive: the order/checkout code depends only on the behaviour, not on any processor; a new processor is additive and touches no existing money path.
  • Positive: a merchant can switch or combine processors as a config change (a different method row), with no deploy.
  • Neutral / to revisit: dynamic module resolution from a stored string means an unknown or misspelled adapter value fails only at call time, not at compile time — the set of valid adapters is a runtime contract, so the string must stay in lockstep with the modules that exist.
  • Neutral / to revisit: the behaviour formally declares only three callbacks; the richer interface (payment links, invoices, refunds) is shared by convention, so "implements a provider" is partly enforced by discipline rather than the compiler.

Alternatives considered

  • A single hardcoded processor: rejected — the platform spans markets no one processor covers.
  • Selecting the provider by currency/region routing rules: rejected — routing is a merchant business choice (their acquiring relationships and fees), not a property of the money; encoding it as platform rules would override the merchant and need constant maintenance. Selection is a per-merchant configuration instead.
  • case/if branching on a provider enum at each call site: rejected — it recouples every money path to every processor and grows quadratically; a behaviour + dynamic dispatch keeps each processor isolated.
← 0007 The CDE runs entirely on Cloudflare with envelope key custody, no HSM 0009 commerce-payments supersedes commerce-adapters via a PanToken FAC compat shim →