Payments run through a provider-agnostic adapter, chosen per merchant
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.
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 anAnyaggregator;CommercePaymentsbridges to the newer service (ADR 0009). (Squareis a scaffold,Atlanticis dead code.) - Data-driven selection. Each merchant configures one or more payment methods; the chosen method carries an
adapterstring, 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 theAnyaggregator, 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
adaptervalue 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/ifbranching 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.