Designing a Rails Service Architecture Around SEPA and Payment Flows
Payments change the design questions a Rails codebase has to answer. Notes on isolating money movement, ledgers, and modeling SEPA as the asynchronous process it is.
Publié le 8 février 2026 · 5 min de lecture
Payments are the one part of a Rails codebase where "just add a model" stops working. The moment real money moves, the design questions change: what happens if the bank's webhook never arrives, what happens if it arrives twice, and who is allowed to read a balance.
Isolate money movement from the rest of the app
We consistently pull payment logic into its own internal service, even inside a monolith, with its own models and its own clearly namespaced tables. The rest of the app talks to it through a small, explicit interface: initiate a transfer, get the current status. Nothing outside that boundary writes to a balance directly.
An append-only ledger, not a mutable balance column
A balance column that gets incremented and decremented in place will eventually disagree with reality, usually discovered during a reconciliation, usually at the worst time. We use an append-only ledger of individual entries instead, and compute the balance by summing them. It costs a query; it buys a complete, replayable audit trail for every euro that moved.
class LedgerEntry < ApplicationRecord
belongs_to :account
validates :amount_cents, presence: true
# entries are created, never updated or deleted
end
def balance_cents(account)
account.ledger_entries.sum(:amount_cents)
end
SEPA is asynchronous by nature, model it that way
A SEPA credit transfer isn't a single API call with an immediate yes or no. It moves through states over hours: submitted, accepted by the bank, settled, or rejected days later for reasons outside your control. We model this explicitly as a state machine with a small, fixed set of transitions, driven by webhooks, and we make every transition idempotent because banking partners will, eventually, send the same webhook twice.
Reconciliation is a feature, not an afterthought
Every payment architecture we've built includes a scheduled job that compares the internal ledger against the banking partner's statement and raises an alert on any discrepancy, however small. The first time it catches something is usually within the first month, and it's usually a race condition nobody anticipated at design time.
Articles liés
Idempotent by Design: Making Your Sidekiq Jobs Safe to Retry
Sidekiq guarantees at-least-once delivery, not exactly-once. Any job that is not safe to run twice is a bug waiting for the right overlap.
The Outbox Pattern in Rails: How to Publish Events Without Losing Data
The gap between saving a record and publishing the message that announces it is where events quietly disappear. Here is how to close it.