System Design ·

Designing an Embedded Finance Platform: Double-Entry Ledgers, Payment Rail Orchestration, and Multi-Provider Settlement at Scale

How to architect embedded finance systems inside SaaS products, covering double-entry ledger design, payment rail selection across Stripe and Adyen, multi-currency settlement, idempotent money movement, reconciliation pipelines, and a build-vs-buy tradeoffs table for each component.

Designing an Embedded Finance Platform: Double-Entry Ledgers, Payment Rail Orchestration, and Multi-Provider Settlement at Scale

Most SaaS products eventually reach the moment where the next feature is financial. Vertical SaaS for trucking needs to pay drivers. Creator platforms need to hold and distribute earnings. B2B tools need to issue corporate cards or provide net-60 terms. At that point, the engineering team faces a decision: call a payment API, or build a real embedded finance platform.

The difference between those two paths is architectural. Calling a payment API is fine for a checkout flow. It fails for anything that needs accurate money accounting, multi-provider resilience, correct reconciliation, or regulatory defensibility. This article covers the system design of the latter: a genuine embedded finance platform, from ledger fundamentals through settlement and reconciliation.

Why Single-Entry Accounting Fails at Scale

The first instinct is a transactions table with a user_id, amount, and type column. This works until you need to answer questions like: why does the sum of all user balances not equal the total funds held in the bank account? Where did this $340 discrepancy come from? Which transaction created it?

With single-entry bookkeeping, you cannot answer those questions without reading every record and tracing every mutation. More critically, you cannot enforce that every debit has a matching credit at the database level. Discrepancies compound silently.

Double-entry accounting solves this at the model level. Every financial event produces exactly two entries: a debit to one account and a credit to another. The invariant is that debits always equal credits. If your ledger sum ever goes non-zero, something is wrong, and you can detect it immediately.

The accounts are not user accounts in the traditional sense. They are abstract buckets: funds_held, payable_to_merchants, revenue, fees_collected, in_flight, settled. A payment from a buyer to a merchant moves funds through several accounts before it reaches the merchant’s spendable balance.

type AccountType = "asset" | "liability" | "equity" | "revenue" | "expense";

interface LedgerAccount {
  id: string;
  name: string;
  type: AccountType;
  // Positive balance meaning depends on account type.
  // Asset: positive = we hold this much
  // Liability: positive = we owe this much
  currencyCode: string;
}

interface LedgerEntry {
  id: string;
  transactionId: string; // groups the two sides of one event
  accountId: string;
  amount: bigint; // in minor units (cents, pence, etc.)
  direction: "debit" | "credit";
  createdAt: Date;
  idempotencyKey: string;
  metadata: Record<string, string>;
}

A few design decisions worth making explicit:

Use bigint for amounts, never float. Floating-point arithmetic introduces rounding errors that accumulate across millions of transactions. Store amounts as integers in the smallest currency unit.

Group entries with a transactionId. Every business event (a payment, a refund, a fee, a settlement) creates a transaction that contains at least two entries. You can then enforce the invariant at insert time.

Store the idempotencyKey on the entry, not just the API request. This lets you deduplicate retries even when the calling context is lost.

Here is a function that atomically records a double-entry transaction and checks the invariant:

async function recordTransaction(
  db: DatabaseClient,
  entries: Array<Omit<LedgerEntry, "id" | "createdAt">>,
): Promise<string> {
  const transactionId = entries[0].transactionId;

  // Verify invariant before writing.
  const totalDebits = entries
    .filter((e) => e.direction === "debit")
    .reduce((sum, e) => sum + e.amount, 0n);

  const totalCredits = entries
    .filter((e) => e.direction === "credit")
    .reduce((sum, e) => sum + e.amount, 0n);

  if (totalDebits !== totalCredits) {
    throw new Error(
      `Ledger invariant violation: debits (${totalDebits}) !== credits (${totalCredits}) for transaction ${transactionId}`,
    );
  }

  await db.transaction(async (tx) => {
    for (const entry of entries) {
      // Idempotency: skip if already written.
      const existing = await tx.query(
        "SELECT id FROM ledger_entries WHERE idempotency_key = $1",
        [entry.idempotencyKey],
      );
      if (existing.rows.length > 0) continue;

      await tx.query(
        `INSERT INTO ledger_entries
         (id, transaction_id, account_id, amount, direction, created_at, idempotency_key, metadata)
         VALUES ($1, $2, $3, $4, $5, NOW(), $6, $7)`,
        [
          crypto.randomUUID(),
          entry.transactionId,
          entry.accountId,
          entry.amount,
          entry.direction,
          entry.idempotencyKey,
          entry.metadata,
        ],
      );
    }
  });

  return transactionId;
}

Account balances are computed by summing entries. Do not cache balances as a separate mutable column — that introduces a second source of truth that will drift. Run a materialized view if query performance demands it, but treat the entries table as authoritative.

Payment Rail Selection and Orchestration

Embedded finance platforms typically need access to multiple payment rails: card networks via processors (Stripe, Adyen, Braintree), ACH for bank-to-bank transfers, wire for high-value domestic and international payments, and increasingly RTP (Real-Time Payments) for near-instant bank settlement in the US.

Each rail has a different cost structure, settlement timing, reversibility window, and failure mode:

RailCostSettlementReversibleBest for
Card (Stripe/Adyen)2.5-3.5%T+1 to T+2180-day chargebackB2C, small transactions
ACH debit$0.05-0.50 flatT+1 to T+360-day return windowB2B, recurring, large amounts
Wire$10-25 flatSame dayGenerally irrevocableVery large amounts
RTP$0.01-0.045 flatSecondsNo reversalPayouts, disbursements
SEPA (EU)€0.20-0.35 flatT+18-week return windowEuropean bank payments

The orchestration layer sits between your product and these rails. Its job is to: select the best rail for each payment, route to the right provider, handle failures and retries, and normalize responses into a single internal event format.

interface PaymentIntent {
  id: string;
  amount: bigint;
  currencyCode: string;
  senderId: string;
  receiverId: string;
  idempotencyKey: string;
  preferredRail?: "card" | "ach" | "wire" | "rtp";
  metadata: Record<string, string>;
}

interface ProviderResult {
  providerId: "stripe" | "adyen" | "modern-treasury" | "column";
  externalId: string;
  status: "initiated" | "pending" | "settled" | "failed";
  rail: string;
  settlementDate: Date | null;
  rawResponse: unknown;
}

async function routePayment(intent: PaymentIntent): Promise<ProviderResult> {
  const rail = selectRail(intent);
  const providers = getProvidersForRail(rail);

  for (const provider of providers) {
    try {
      const result = await provider.submit(intent);
      await recordProviderAttempt(intent.id, provider.id, result);
      return result;
    } catch (err) {
      await recordProviderAttempt(intent.id, provider.id, {
        status: "failed",
        error: String(err),
      });
      // Continue to next provider in priority order.
    }
  }

  throw new Error(`All providers failed for payment ${intent.id}`);
}

function selectRail(intent: PaymentIntent): string {
  if (intent.preferredRail) return intent.preferredRail;

  // Simple heuristic: use RTP for large domestic payouts,
  // ACH for recurring B2B, card for everything else.
  if (intent.amount > 100_000_00n && intent.currencyCode === "USD") {
    return "rtp";
  }
  if (intent.metadata["type"] === "recurring_b2b") {
    return "ach";
  }
  return "card";
}

Provider failover is the part that typically gets skipped in v1 and causes an outage in production. Stripe does go down. ACH batches do fail at the originator level. Wire cutoffs are real. Build the fallback map before you need it: if Stripe card processing fails, can you fall back to Adyen? If ACH via Modern Treasury fails, do you have a secondary originator? The answer determines how resilient your payout SLA actually is.

Multi-Currency Settlement and FX Handling

Once you have multiple currencies, you have an FX problem. The naive approach is to convert everything to a single base currency at the point of transaction using a spot rate from an API. This creates two classes of problem: rate inconsistency (two transactions “in the same batch” may use rates minutes apart) and P&L risk (you quoted a rate to the user before you locked it with a provider).

The correct model separates FX into three explicit steps:

  1. Quote: At the moment the user initiates a cross-currency payment, fetch and lock a rate with an FX provider (Wise, Airwallex, or a bank). Store the locked rate and its expiry.
  2. Record: When the payment settles, record the actual settled amounts in both currencies against your quote. The difference between the quoted rate and the actual settlement rate is FX slippage, and it lives in your ledger as an explicit entry.
  3. Mark-to-market: For any in-flight multi-currency balances, run a nightly job that revalues them at current rates and records unrealized FX gain/loss entries.
interface FxQuote {
  id: string;
  fromCurrency: string;
  toCurrency: string;
  rate: number; // fromCurrency units per toCurrency unit
  lockedUntil: Date;
  providerQuoteId: string;
}

interface MultiCurrencyEntry {
  ledgerEntryId: string;
  nativeAmount: bigint;
  nativeCurrency: string;
  baseAmount: bigint; // in platform base currency (e.g. USD)
  baseCurrency: string;
  fxRate: number;
  fxQuoteId: string | null;
}

Do not round FX amounts before storing them. Accumulate the fractional remainder and apply it to the last entry in a batch. This prevents consistent rounding errors that aggregate into real money across large transaction volumes.

Idempotent Money Movement

Money movement is one of the few domains where a repeated operation has concrete financial consequences. An ACH debit submitted twice is a double charge. A payout submitted twice is a double payment. Network timeouts, retries, and webhook redeliveries all create opportunities for duplication.

Idempotency must be enforced at three layers:

Layer 1: Your API to the provider. Stripe and Adyen both support idempotency keys on every write operation. Always pass one. Derive it deterministically from your internal payment ID, not from a UUID generated at request time.

Layer 2: Provider webhooks to your system. Providers deliver webhooks at-least-once. Your webhook handler must be idempotent: process the event, then record that you processed it. The simplest implementation is a processed_webhook_events table with a unique constraint on the external event ID.

Layer 3: Ledger entries. As shown above, the idempotency_key column on ledger_entries prevents double-recording even if the entry function is called twice for the same event.

async function handleProviderWebhook(
  db: DatabaseClient,
  eventId: string,
  payload: unknown,
): Promise<void> {
  // Check if already processed.
  const existing = await db.query(
    "SELECT id FROM processed_webhooks WHERE external_event_id = $1",
    [eventId],
  );
  if (existing.rows.length > 0) return;

  // Process the event.
  await processPaymentEvent(db, payload);

  // Mark as processed. This must be in the same transaction
  // as processPaymentEvent to avoid a race.
  await db.query(
    "INSERT INTO processed_webhooks (external_event_id, processed_at) VALUES ($1, NOW())",
    [eventId],
  );
}

One non-obvious failure: a webhook that partially processes before the server crashes. The mark-as-processed insert never runs, so the webhook redelivers, and you process it again. Use a database transaction that wraps both the business logic and the mark-as-processed insert. If the business logic fails, the mark-as-processed rolls back. If everything succeeds, both commit atomically.

Reconciliation Pipelines

Reconciliation is the process of comparing your internal ledger against external records (bank statements, provider settlement files, card network reports) and identifying discrepancies before they compound.

Most platforms skip this until a discrepancy causes a real problem, at which point months of data need to be unwound. Build the reconciliation pipeline before you have more than a few thousand transactions.

The pipeline has three stages:

Stage 1: Ingest external records. Providers deliver settlement files in various formats (CSV, MT940 bank statements, Stripe balance transactions API). Parse and normalize them into a common schema.

Stage 2: Match internal entries to external records. Each external record should match exactly one internal ledger entry. Match on provider transaction ID, amount, currency, and settlement date.

Stage 3: Flag and route discrepancies. Unmatched records on either side are discrepancies. Classify them: timing differences (in-flight vs settled), amount mismatches (fee miscalculation, FX slippage outside tolerance), and missing records (a payment processed by the provider with no matching internal entry, or vice versa).

interface ExternalSettlementRecord {
  providerTransactionId: string;
  amount: bigint;
  currencyCode: string;
  settledAt: Date;
  type: "payment" | "refund" | "fee" | "payout";
  rawData: unknown;
}

interface ReconciliationResult {
  matched: number;
  unmatchedExternal: ExternalSettlementRecord[];
  unmatchedInternal: LedgerEntry[];
  amountDiscrepancies: Array<{
    externalId: string;
    internalId: string;
    externalAmount: bigint;
    internalAmount: bigint;
    delta: bigint;
  }>;
}

async function reconcileSettlementFile(
  db: DatabaseClient,
  records: ExternalSettlementRecord[],
): Promise<ReconciliationResult> {
  const result: ReconciliationResult = {
    matched: 0,
    unmatchedExternal: [],
    unmatchedInternal: [],
    amountDiscrepancies: [],
  };

  for (const record of records) {
    const internal = await db.query(
      `SELECT le.* FROM ledger_entries le
       JOIN payment_provider_attempts ppa ON ppa.transaction_id = le.transaction_id
       WHERE ppa.external_id = $1`,
      [record.providerTransactionId],
    );

    if (internal.rows.length === 0) {
      result.unmatchedExternal.push(record);
      continue;
    }

    const entry = internal.rows[0] as LedgerEntry;
    if (entry.amount !== record.amount) {
      result.amountDiscrepancies.push({
        externalId: record.providerTransactionId,
        internalId: entry.id,
        externalAmount: record.amount,
        internalAmount: entry.amount,
        delta: record.amount - entry.amount,
      });
    } else {
      result.matched++;
    }
  }

  return result;
}

Run reconciliation daily at minimum, triggered after each provider settlement file arrives. For high-volume platforms, run it in near-real-time by streaming provider webhooks through the matcher.

Discrepancies should create work items routed to a finance operations queue, not just logged and forgotten. Each discrepancy has a root cause: FX rounding, a fee that was not recorded, a provider error, or a bug in your ledger code. Track resolution time per discrepancy type. If fee miscalculations appear consistently on ACH transactions, that is a code bug, not a one-off.

Regulatory Dimensions

Embedded finance platforms interact with two major regulatory frameworks that constrain architecture decisions.

Money transmission licensing applies when you hold customer funds. If your platform holds user balances (not just processes payments), most US states require a money transmitter license. The common alternative is to partner with a licensed entity: a sponsor bank (Column, Lead Bank, Evolve) or a BaaS middleware layer (Unit, Treasury Prime, Synctera). This shifts the licensing burden to your partner but constrains your ledger design to match their reconciliation requirements.

PCI DSS scope is determined by what card data touches your infrastructure. If your frontend never handles raw card numbers (you use Stripe.js, Adyen’s Web Components, or a hosted payment page), you operate under SAQ A, which is a self-assessment with minimal engineering requirements. The moment raw card data touches your servers, you enter SAQ D territory, which requires quarterly network scans and annual on-site audits. Design your frontend to keep card data out of your infrastructure entirely.

These are architecture constraints, not afterthoughts. Decide on your licensing path and PCI scope before writing any financial code, because both decisions shape which components you can build in-house versus must source from a regulated partner.

Build vs. Buy Tradeoffs

Every embedded finance platform starts with a build-or-buy decision per component. Here is an honest assessment:

ComponentBuildBuy (example providers)When to build
Double-entry ledger2-4 weeks, high complexityTigerbeetle, Ledger, Modern Treasury ledgerWhen your transaction semantics are non-standard or you need sub-millisecond posting
Payment processing1-2 months, ongoing maintenanceStripe, Adyen, BraintreeAlmost never: card network certification alone takes a year
ACH origination3-6 months + bank sponsor + NACHA certificationModern Treasury, Column, DwollaAlmost never: regulatory overhead is prohibitive
Wire initiationSimilar to ACHSame providersAlmost never
FX2-3 months for basic, ongoing for complianceWise Business, Airwallex, CurrencycloudOnly at very high volume where spread matters materially
Reconciliation pipeline3-6 weeksLedge, Hyperplane, manualOften worth building: highly specific to your data model
Card issuing6-12 months + network sponsorshipMarqeta, Lithic, HighnoteAlmost never
Fraud detection2-3 months for rules engine, months for MLSardine, Sift, Stripe RadarRules engine is worth building; ML models are not

The pattern: buy anything that touches regulated infrastructure (card networks, ACH, wire, card issuing). Build anything that is specific to your data model and business logic (ledger, reconciliation, FX recording). Buy fraud detection tooling and layer your own rules on top.

The ledger is the one component where building in-house consistently produces better outcomes than the managed alternatives, because your account structure, transaction semantics, and balance computation logic are specific to your product. Buying a generic ledger and mapping it to your model introduces an impedance mismatch that compounds as the product evolves.

Production Considerations

A few issues that consistently appear in production embedded finance systems:

Ledger table partitioning. A ledger_entries table with years of history and billions of rows becomes a query bottleneck. Partition by month from day one. Queries for account balances within a recent period stay fast; historical reconciliation queries hit the right partition directly.

Idempotency key expiry. Stored idempotency keys for payment requests should have a TTL (typically 24 hours, matching the provider’s idempotency window). After that window, a duplicate submission is a new payment, not a retry. The key expiry logic must match the provider’s behavior exactly.

Settlement timing and float. Between when a payment is initiated and when funds settle to your account, you have float: the funds are in-flight. Model this explicitly with an in_flight account in your ledger. If your platform needs to pay out before funds settle (instant payout products), you are lending against float, which creates credit risk that needs to be priced and managed.

Webhook delivery ordering. Providers do not guarantee webhook delivery order. A payment.settled webhook can arrive before payment.initiated. Design your webhook handler to be stateless: look up the current state of the payment from the provider API if you receive a webhook for an unknown payment ID, rather than assuming delivery order.

Audit log immutability. The ledger entries table must be append-only. Never update or delete an entry. If an entry was wrong, record a correcting entry. This is both a regulatory requirement and a practical one: you cannot reconstruct account history from a table where rows have been mutated.

The Actual Complexity

The code in this article is representative of the design, not the complete implementation. The production version of each component has more edge cases than the happy path suggests. ACH returns arrive days after the original debit. Card chargebacks can reverse a settled payment 180 days later. FX rates expire during high-volatility events and your locked quote becomes invalid. Provider APIs change without notice.

The teams that ship embedded finance well do not treat it as a feature. They treat it as infrastructure with the same discipline they apply to databases: schema migrations never mutate live columns, every write is idempotent, every external interaction has an audit trail, and discrepancies are investigated, not dismissed as noise.

The double-entry ledger is the foundation that makes all of this tractable. Without it, you are tracking money in a system that cannot verify its own correctness. With it, every discrepancy becomes visible the moment it happens, and the investigation has a clear starting point.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.