FR | EN
Parler de votre projet

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.

RAILS APP PAYMENT SERVICE LEDGER (APPEND-ONLY) BANKING PARTNER (SEPA RAIL) WEBHOOK STATE MACHINE
Isolating money movement from the rest of the app

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.