Payment platforms are usually sold on consumer-visible features (Virtual Account, QR, dynamic QR, merchant onboarding) and broken by the integration with banking systems. The features are the surface; the integrations are the platform. Designing the workflow to absorb the integration quirks is the work that separates a payment demo from a payment platform.
The patterns below come from the multi-tenant payment platform: IPN-driven state machines, virtual account issuance, dynamic fee calculation as a first-class concern, idempotent reconciliation, and file handling that does not trust the wire.
Treat IPN as the source of truth
A common mistake is to model payment state around the API the merchant calls. The honest model puts the bank at the center: a payment transitions through received → matched → credited → reconciled, and the bank's IPN is the event that drives transitions. The merchant API is a projection of that state machine, not the driver.
The state machine should be explicit. A payment in received is one whose IPN has arrived but whose internal fields have not yet been computed (fee, accounting codes, partner notification queue). A payment in matched has been confirmed against a merchant's virtual account. A payment in credited has been confirmed by the bank and routed to merchant balance. A payment in reconciled has been reconciled against the daily SFTP feed.
The transition rules have to be small, versioned, and visible. A payment platform with no state machine is a payment platform with hidden state.
Virtual accounts are the right abstraction for bank IPNs
A virtual account (VA) is the contract between the platform and the bank: a number the bank knows, an internal identifier the platform knows, and a mapping between them. The IPN carries the VA; the platform translates that to a merchant, a payment, and an internal ledger entry.
The pattern that holds up is to keep VA issuance close to merchant onboarding. The platform issues one or more VAs per merchant; the bank maps payments on those VAs back to merchant payments on the platform's ledger. This isolates bank-integration quirks (delays, duplicates, chargebacks) inside the VA layer and keeps the merchant-facing state machine clean.
Dynamic fee calculation is its own service
Fees that depend on payment amount, partner, tier, and a configurable policy are not a side concern — they are a service. Fee calculation should be a separate consumer that reads payment events, applies the policy version that was current at the time the payment was created, and writes the fee to the ledger as a separate event.
Two design choices earn their keep:
- Snapshot the policy at payment creation. A payment's fee is computed against the policy that was live when the payment was initiated, not the current policy. Otherwise retroactive policy changes overwrite historical fees.
- Make the policy data, not code. Fee rules should be loadable as a structured bundle, refreshable out of band, and rolled back without a deploy. A policy-as-code model that requires a deploy to change a fee tier is a model that will not change often enough.
Reconciliation is a separate consumer, not a cron job
The daily SFTP feed from the bank is the bank's statement of what happened in their world. Reconciling it against the platform's ledger is a separate workflow with its own idempotency contract.
The pattern that holds up:
- Pull the SFTP file into S3 as raw bytes (immutable).
- Parse into a normalized record shape with stable, versioned fields.
- Match each record against the platform's ledger by reference (the bank's reference is the join key).
- For matches: write the reconciliation event to the ledger and mark the record.
- For unmatched: route to an operator workflow with the original record and the platform's view.
The SFTP quirks are real — partial files, files re-sent mid-day, files with extended ASCII in merchant names — and the parser should be able to handle them without the rest of the platform caring. Treat reconciliation as a consumer of two streams (SFTP files + the internal ledger) that produces a third stream (reconciliation events) and the rest of the design falls out cleanly.
Idempotency is the storage layer, not the handler
Bank IPNs will be retried, occasionally duplicated, and occasionally reordered. The platform's job is to make each of those harmless. The pattern is the same as in any other consumer: a per-payment idempotency key derived from the bank's reference id, with the storage layer enforcing uniqueness.
A handler that uses an in-memory seen set is not idempotent across restarts. A handler that uses a seen set in Redis but forgets the timing window is not idempotent across TTL expiry. A handler that writes through a unique index keyed on the bank's reference is idempotent across restarts, TTLs, deploys, and partial failures — which is what the platform needs.
Observability per state transition
Every payment state transition should be a structured log event with the previous state, the new state, the trigger (IPN id, reconciliation record id, manual operator action), and the timestamp. Aggregate state is the wrong level for debugging; per-payment history is what an operator reads on a Friday afternoon when a partner asks why a single payment is stuck.
The minimum shape
A payment platform that survives real banks has: an IPN-driven state machine at the core; virtual accounts as the bank-facing abstraction; dynamic fee calculation as a separate consumer against a snapshotted, data-driven policy; reconciliation as a separate consumer of SFTP + the internal ledger; idempotency at the storage layer; and structured logs per state transition. Each capability assumes the one before it; build them in order and the platform stops being fragile around the integrations.