Skip to content
ERPNext Data Model
Esc
navigateopen⌘Jpreview
On this page

Multi-Provider Payment Integration Architecture

A design reference for handling external payment providers inside a single-codebase ERP system

1. Requirements

1.1 Functional requirements

  • Support many external payment providers (card processors, wallets, phone/soundbox-based collection, bank-initiated debit) without a separate app or deployment per provider — all providers are adapters within one system.
  • Every provider must be scoped to a legal entity (a company/branch/subsidiary operating its own books), because each legal entity has its own chart of accounts, base currency, and banking relationships.
  • A payment collected through a provider must land in a specific ledger account that belongs to the legal entity that owns the transaction — never in a shared, entity-agnostic bucket.
  • The same linking mechanism must work for three collection surfaces: an emailed/hosted payment link (online invoice payment), a phone-initiated charge (tap-to-pay / soundbox at point of sale), and manually recorded settlements.
  • Support multi-currency: a provider can be configured once per currency it settles in, and the system must reconcile transaction currency against the paying party’s currency and the legal entity’s base currency.
  • Every outbound call to a provider and every inbound webhook/callback must be logged in a provider-agnostic, replayable way — this log is also used by non-payment integrations (shipping, tax, communication), so payments are one consumer of a general-purpose integration log, not a bespoke one.
  • Once a payment is confirmed, the system must produce a normal accounting settlement entry automatically, allocated against the originating document(s), with correct currency conversion.
  • Downstream, settlements must be reconcilable against actual bank statement lines, and unallocated credits must be matchable against outstanding invoices independent of which provider produced them.
  • Recurring/subscription billing must be able to reuse the same provider-linking layer to request payment for each generated invoice.

1.2 Non-functional requirements

  • Idempotency: enabling a provider twice, or receiving a duplicate webhook, must not create duplicate ledger accounts, duplicate links, or duplicate settlements.
  • Extensibility: adding a new provider should require implementing a small, fixed adapter interface — not touching core accounting code.
  • Auditability: every external call and every state transition (requested → initiated → paid/failed) must be inspectable after the fact, including partial payments.
  • Graceful degradation: if auto-provisioning a ledger account fails, the system must surface a clear manual-setup path rather than silently dropping the provider configuration.
  • Low operational overhead: no dedicated payment microservice/app to deploy, patch, or version separately from the core system.

1.3 Constraints

  • Single monolithic system (single codebase, single deployment unit); “multi-provider” is achieved through an internal adapter registry, not separate services.
  • Each legal entity’s chart of accounts is independent — nothing about a provider link can assume a single global set of books.
  • Provider credentials/settings are configured through the same admin surface as the rest of the system (no separate provider-specific admin app).

2. High-Level Design

2.1 Component diagram

2.2 Data flow — provider enablement (the “account creation” path)

This path is a short annotated procedure rather than a branching graph, so it is written as steps:

  1. Admin enables Provider X for Legal Entity A. The provisioning service fires on the “enabled” event.
  2. Resolve the ledger account against Entity A’s chart of accounts:
    1. search for a ledger account named “Provider X” under Entity A;
    2. if not found, create a new Bank-type ledger account under Entity A’s “Bank Accounts” group;
    3. return the account and its currency.
  3. Check the Provider Ledger Link table for an existing link for (Provider X, currency):
    • found → stop, no-op (this is what makes re-enablement idempotent);
    • not found → create the link record shown below.
  4. Demote prior defaults. Any other default link for (Entity A, Provider X) is flipped to non-default.

The link record created in step 3:

{ provider:       Provider X,
  legal_entity:   Entity A,
  ledger_account: <found or created in step 2>,
  currency:       <account currency>,
  channel:        Email | Phone | Other,
  is_default:     true }

3. Deep Dive

3.1 Data model

Legal Entity Top-level scoping unit. Owns its own chart of accounts, base currency, default bank account, and default payment method. Every entity below is either owned by, or filtered by, a legal entity.

Ledger Account (chart of accounts, per legal entity) Hierarchical. A “Bank Accounts” group node exists per entity; provider settlement accounts are leaf accounts of type Bank under that group. Each account has its own currency.

Payment Method (e.g., Cash, Cheque, Wire Transfer, Card, a named external provider) A generic, entity-agnostic label for how money moves. Each legal entity maps each method it accepts to a specific default ledger account via a Payment Method Ledger Link — this is the same pattern used for external providers, just one level more general. (In other words: the provider-linking mechanism described here is a special case of a broader “payment method → ledger account, scoped per legal entity” pattern that also covers cash drawers, cheque clearing accounts, and wire accounts.)

Provider (registered external payment provider — a card processor, wallet, or phone-based collector) A named, enabled integration. Each provider ships an adapter implementing a fixed interface (below). Enabling a provider means saving its credentials/settings for a given legal entity (or globally, with per-entity links created afterward).

Provider Ledger Link (the entity this question is really about) The join record that makes a provider usable for real transactions:

Field Purpose
provider which external provider
legal_entity which company’s books this applies to
ledger_account the Bank-type account payments settle into
currency derived from the ledger account, read-only
channel Email / Phone / Other — which collection surface this link serves
is_default one default per (legal entity, provider); enforced at save time
message_template default text/template used when generating a hosted payment link

Uniqueness is effectively (provider, legal_entity, currency) — a provider can have multiple links per entity only if it settles in more than one currency.

Payment Intent Represents an ask for payment against a source document (sales order, sales invoice, purchase order, purchase invoice, fee/tuition invoice, POS sale). Carries: amount, currency, channel, the resolved provider link, and a status lifecycle:

External Call Log Generic record of any outbound API call or inbound webhook, not payment-specific — the same mechanism logs calls to shipping, tax, and messaging integrations. Fields include reference to the originating document, request/response payload, and status (Queued, Authorized, Completed, Failed, Cancelled). For partially-paid intents, the system sums only Completed log entries to compute how much is still outstanding — this is what makes repeated phone-charge attempts on the same invoice safe.

Settlement Entry The actual accounting posting created once a Payment Intent is marked paid. Determines the correct debit/credit accounts from the source document, applies an exchange rate when the transaction currency differs from the legal entity’s base currency or the paying party’s account currency, and allocates the payment across one or more open items (invoices, payment schedule rows).

Bank Statement Line / Bank Reconciliation Where the provider’s payout/settlement eventually shows up in the actual bank feed. Reconciliation matches statement lines against Settlement Entries (or creates them if a provider payout arrives before the entry does), closing the loop between “provider confirmed payment” and “cash actually arrived.”

Payment Reconciliation (batch) Independent of provider: matches any unallocated credit (advance, overpayment, provider settlement not yet allocated) against outstanding invoices for a legal entity/party. This is where provider-sourced and manually-recorded payments converge into the same allocation logic.

Recurring Billing Schedule (subscription plans + a periodic process) Generates invoices on a schedule from a plan. It does not bypass the provider layer — each generated invoice can itself get a Payment Intent through the exact same path described above, so recurring billing is a producer of source documents, not a separate payment mechanism.

3.2 Provider adapter interface (the plugin contract)

Every provider implements a small, fixed set of methods; the core system never contains provider-specific logic:

ProviderAdapter:
  validate_transaction_currency(currency) -> raises if unsupported
  validate_minimum_transaction_amount(currency, amount) -> raises if below floor
  get_payment_url(amount, currency, reference, payer_info) -> hosted URL
  request_for_payment(amount, currency, reference, phone_number) -> initiates phone charge
  on_payment_intent_submission(payment_intent) -> optional pre-submit hook
  handle_webhook(payload) -> updates External Call Log + Payment Intent status

The core system resolves “which adapter” purely from the provider field on the Provider Ledger Link — this is what allows arbitrarily many providers to coexist in one codebase with zero core-code branching per provider.

3.3 Provisioning algorithm (account creation, in full)

This is triggered by a “provider enabled” event (fired when an admin saves a provider’s settings as active for a legal entity):

  1. Resolve legal entity. Use the entity explicitly targeted by the settings; if none was specified, fall back to the system’s configured default entity. If no entity can be resolved at all, abort — nothing is created silently.
  2. Find existing ledger account. Search the resolved entity’s chart of accounts for an account literally named after the provider (checking both a localized/translated name and the raw name, since the account name is user-facing).
  3. Auto-provision if missing. If no matching account exists, create a new leaf account of type Bank, nested under the entity’s “Bank Accounts” group. This is the same routine used to create any bank account during initial company setup — provider accounts aren’t a special case of the chart of accounts, just a normal bank-type leaf.
  4. Abort with a manual-setup prompt if account auto-provisioning itself fails (e.g., the entity has no “Bank Accounts” group yet) — the admin is told to create one manually rather than the system guessing.
  5. Idempotency check. Look for an existing Provider Ledger Link for this (provider, currency) pair. If one exists, stop — re-enabling a provider (e.g., after a reinstall or reconfiguration) must not create a duplicate link.
  6. Create the link. Insert a new Provider Ledger Link with is_default = true, referencing the found/created ledger account and its currency.
  7. Demote prior defaults. A save-time rule on the link enforces that only one link per (legal_entity, provider) can be default — creating a new default automatically flips any existing one for that same entity/provider to non-default. This runs as a validation step on every save of a link, not just at creation, so manually adding a second link and marking it default is equally safe.

3.4 Error handling & retry logic

  • Duplicate enablement: handled by the idempotency check in step 5 above — safe to re-run the enablement event any number of times.
  • Partial/duplicate charges (phone/POS): the system recomputes “amount still owed” by summing only Completed External Call Log entries tied to the Payment Intent, so a retried or duplicate charge attempt against the same intent doesn’t double-count what’s outstanding.
  • Stale intents: when a new Payment Intent is raised against a document that already has outstanding intents, prior unresolved intents (and their in-flight external calls) are explicitly cancelled first, preventing two live charge attempts against the same invoice.
  • Currency mismatches: the adapter’s validate_transaction_currency is called before any external call is made, and the ledger account’s currency (fixed at provisioning time) constrains what the link can be used for — a currency mismatch fails fast, before hitting the provider’s API.
  • Webhook/callback failures: logged in the External Call Log with a Failed status and the raw payload retained, so failures are diagnosable without needing provider-side logs.
  • Cross-currency settlement: if the transaction currency differs from the paying party’s account currency or the legal entity’s base currency, the Settlement Entry applies the appropriate exchange rate on posting rather than assuming 1.

3.5 API/interaction contract (illustrative)

POST /payment-intents
  { reference_type, reference_id, provider_link? }
  → resolves provider_link if omitted (default for the document's legal entity)
  → returns { payment_intent_id, status: "Requested" }

GET /payment-intents/{id}/pay-url
  → invokes adapter.get_payment_url(...)
  → returns hosted URL (email/online channel)

POST /payment-intents/{id}/charge  (phone/POS channel)
  → invokes adapter.request_for_payment(...)
  → creates External Call Log entry, returns { status: "Initiated" }

POST /webhooks/{provider}/callback   (provider → system)
  → invoked by the external provider
  → updates External Call Log + Payment Intent status
  → on success, triggers Settlement Entry creation

4. Scale and Reliability

  • Load pattern: bursty, driven by invoicing cycles and checkout traffic rather than steady-state — webhook ingestion and hosted-payment-URL generation are the hot paths, not the provisioning flow (which runs once per provider/entity/currency combination and is cheap).
  • Horizontal scaling: webhook handling and payment-URL generation are stateless request handlers and scale horizontally behind a queue; the provisioning algorithm itself should run inside a single transaction/lock per (legal_entity, provider, currency) to avoid a race creating two ledger accounts or two default links under concurrent “enable” clicks.
  • Idempotency as the reliability backbone: because both provisioning and payment confirmation are guarded by existence checks (steps 5 and the Completed-only summation), safe retries are possible everywhere without a dedicated distributed-lock service — this is deliberately simpler than it would need to be in a system without those checks.
  • Failover: if a provider’s callback never arrives (network partition, provider outage), the Payment Intent remains Initiated indefinitely; a periodic reconciliation job should poll provider status for stuck intents rather than relying solely on webhooks — this is a gap worth hardening (see below).
  • Monitoring/alerting: alert on Failed External Call Log entries exceeding a threshold per provider (signals a credential or provider-side outage), and on Payment Intents stuck in Initiated past a TTL.

5. Trade-off Analysis

Decision Trade-off
Single codebase with an adapter registry, no dedicated payment service Simpler ops (one deployment, one data store, one auth model) at the cost of coupling provider blast-radius to the whole system — a bug in one adapter can be deployed alongside, and potentially affect, unrelated modules. Acceptable while provider count and team size are small; revisit if any single provider needs independent scaling or a different release cadence.
Provider Ledger Link scoped per legal entity + currency, not globally Correct multi-entity accounting (no cross-entity leakage) at the cost of more setup records as entities/currencies multiply (N entities × M providers × K currencies). Mitigated by auto-provisioning, but still an admin surface to manage at scale.
Auto-creating a ledger account on provider enablement Removes manual chart-of-accounts setup for the common case, but can silently create accounts an accountant didn’t expect if a provider is enabled by a non-finance admin. Worth gating behind a permission check or at least a confirmation step as the org grows.
Idempotency via existence checks rather than distributed locks Cheap and sufficient for the enablement flow’s low write frequency; would not hold up if provisioning became a high-concurrency path (it currently isn’t).
Generic External Call Log shared across all integrations (not payment-specific) Reuses infrastructure and gives a single audit surface, but means payment-specific queries (e.g., “sum completed amounts”) have to filter a general-purpose table rather than query a purpose-built one — a minor query-shape cost for a real reuse win.
Reconciliation via webhook + separate bank-statement matching (two independent confirmations) More resilient (doesn’t trust the provider’s webhook alone) but means “paid” (per the provider) and “settled” (per the bank) are genuinely different states the system must track separately — this is intentional, not an oversight, but it does mean support teams need to understand both states.

6. What to Revisit as the System Grows

  • Provisioning concurrency: add an explicit lock or unique constraint enforcement at the database level for (legal_entity, provider, currency) rather than relying on an application-level existence check, once provider enablement stops being a rare, admin-driven action.
  • Stuck-intent recovery: add active status polling for providers that support it, rather than depending solely on webhooks, to bound how long a Payment Intent can sit in Initiated.
  • Permissioning around auto-provisioned accounts: introduce an approval step before a newly auto-created ledger account is usable, once the number of admins who can enable a provider grows beyond a small finance-adjacent group.
  • Provider adapter isolation: if any provider adapter needs a different scaling profile, language runtime, or release cycle than the core system, that specific adapter (not the whole layer) is the candidate to peel out into its own service — the adapter interface already defines a clean seam for that.
  • Multi-currency link explosion: if the number of (entity, provider, currency) combinations grows large, consider a currency-agnostic ledger account with conversion at posting time instead of one account per currency, trading some accounting granularity for setup simplicity.

Was this page helpful?