The Book: Technical Reference & Study Guide

Double-entry engine, database-enforced invariants, multi-asset chart of accounts, and lifecycle examples.
Entities: depasify_sl, plenifi • Invariants: I1, I2, I3 (Active) • Currencies: 9 Fiat + 11 Crypto • Coverage: Banking, Cards, Crypto, Trades, Treasury, Provider Costs • Reads: Shadow / Authoritative

1. The Paradigm Shift

The legacy ledger tracked balances using isolated, signed numbers on individual client records. The Book establishes a formal, append-only double-entry system with balance sheet accounting across all external providers and internal liabilities.

Legacy Ledger (LedgerEntry)

  • Single-entry: Creates isolated records (e.g. +100 EUR). No counterpart leg records where the money is kept.
  • Destructive edits: Canceled or failed flows call destroy or update(amount: 0), erasing audit history.
  • Fragmented writes: Writes were distributed across 36 files and 63 execution paths.
  • No provider positions: Incapable of reconciling nostro bank accounts, crypto custody, or desk spreads.
  • Float arithmetic: Lost cents on fee splits due to floating-point math.

The Book (Double-Entry Core)

  • Strict balance: Every transaction produces a JournalEntry with ≥ 2 lines summing to zero per currency.
  • Append-only: Enforced by raw PostgreSQL triggers. Edits and deletions are rejected at the DB engine level.
  • Unified path: All domain events route through Book::Pilot and Book::Post.
  • Complete balance sheet: Tracks client liabilities, provider nostros, wallet custody, acquirer receivables/payables, rolling reserves, transit buffers, and revenue.
  • High-precision decimals: 10 decimals for crypto and banker's rounding on BigDecimal.

Normal Balances & Sign Conventions

In The Book, Debits are strictly positive (+) and Credits are strictly negative (-). Every journal balances to zero: ∑ Debits + ∑ Credits = 0.

Classification Normal Balance Debit (+) Meaning Credit (-) Meaning Example Accounts
Asset Debit (+) Increases asset balance Decreases asset balance nostro.open_payd.EUR, custody.dfns.USDC, fx_position.EUR, asset.acquirer_receivable.worldpay.EUR
Liability Credit (-) Decreases liability (payout / debit) Increases liability (deposit / credit) client_funds.<account_id>.EUR, client_rolling_reserve.<customer_id>.EUR, liability.acquirer_payable.checkout.EUR
Revenue Credit (-) Reverses revenue Recognizes earned revenue revenue.trading_fee.EUR, revenue.blockchain_fee.USDC, revenue.card_fee.EUR
Equity Credit (-) Decreases equity / suspense debit Increases equity / suspense credit equity.opening_suspense.EUR, equity.manual_adjustment.USD

2. Database-Enforced Invariants

Invariants live in PostgreSQL (db/book_invariants.sql). If application code attempts an unbalanced or mutating query, PostgreSQL throws an immediate error.

Invariant Scope Enforcement Mechanism Failure Behavior
I1: Zero-Sum Balance Per currency per journal Statement trigger book_journal_lines_balanced + constraint trigger book_journal_entries_balanced Raises check_violation if lines < 2 or SUM(amount) != 0 for any currency.
I2: Row Immutability journal_entries & journal_lines Triggers book_journal_entries_immutable, book_journal_lines_immutable Raises check_violation on any SQL UPDATE, DELETE, or TRUNCATE.
I3: Entity & Period Integrity Per journal Function book_assert_journal_balanced Rejects mixed legal entities (e.g. depasify_sl vs plenifi) or postings into closed accounting periods.
Zero-Amount Guard Every line Table constraint CHECK (amount <> 0) Rejects line with amount = 0.

Trigger Code Excerpt (db/book_invariants.sql)

CREATE OR REPLACE FUNCTION book_assert_journal_balanced(p_journal_entry_id bigint) RETURNS void AS $$
DECLARE
  v_currency text;
  v_total numeric;
  v_line_count integer;
BEGIN
  SELECT count(*) INTO v_line_count FROM journal_lines WHERE journal_entry_id = p_journal_entry_id;
  IF v_line_count < 2 THEN
    RAISE EXCEPTION 'book: journal % must have at least 2 lines (found %)', p_journal_entry_id, v_line_count
      USING ERRCODE = 'check_violation';
  END IF;

  FOR v_currency IN SELECT DISTINCT currency FROM journal_lines WHERE journal_entry_id = p_journal_entry_id LOOP
    SELECT COALESCE(SUM(amount), 0) INTO v_total FROM journal_lines
    WHERE journal_entry_id = p_journal_entry_id AND currency = v_currency;

    IF v_total <> 0 THEN
      RAISE EXCEPTION 'book: journal % does not balance in % (sum = %)', p_journal_entry_id, v_currency, v_total
        USING ERRCODE = 'check_violation';
    END IF;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

3. Chart of Accounts

Accounts are scoped to a LegalEntity (default: depasify_sl) and follow the key format: <kind>.<provider_or_category>.<currency>.

System Key Pattern Kind Normal Description
custody.<provider>.<ccy> Asset Debit (+) Crypto balances at external custody providers (dfns, kraken, bit2me, b2c2, binance, modern_treasury).
nostro.<provider>.<ccy> Asset Debit (+) Bank account balances with fiat partners (open_payd, open_payd_uk, bbva, modern_treasury, equals, circle).
asset.crypto_payouts_in_transit.<ccy> Asset Debit (+) Holding account for accepted on-chain crypto payouts pending broadcast and confirmation.
asset.payouts_in_transit.<ccy> Asset Debit (+) Holding account for accepted fiat payouts pending banking rail settlement.
fx_position.<ccy> Asset Debit (+) Desk currency position resulting from executed client exchange trades.
client_funds.<account_id>.<ccy> Liability Credit (-) Client account funds owed (negative balance represents client liability).
customer_collections.<collections_id>.<ccy> Liability Credit (-) Vault partner fee share collections payable to partner accounts.
asset.acquirer_receivable.<acquirer>.<ccy> Asset Debit (+) Card acquiring receivables pending settlement from card acquirers (checkout, worldpay).
liability.acquirer_payable.<acquirer>.<ccy> Liability Credit (-) Pass-through interchange (IC) and scheme fees owed to card networks and acquirers.
client_rolling_reserve.<customer_id>.<ccy> Liability Credit (-) Collateral held from card processing volume to cover chargeback risks (client liability, not equity/revenue).
revenue.card_fee.<ccy> Revenue Credit (-) Net card payment processing margin retained by Depasify after pass-through costs and partner fee cuts.
revenue.trading_fee.<ccy> Revenue Credit (-) Net trading fees retained by Depasify.
revenue.fx_spread.<ccy> Revenue Credit (-) Net pricing spread recognized on trades.
revenue.blockchain_fee.<ccy> Revenue Credit (-) Network transaction fees retained by Depasify.
revenue.overnight_liquidity_fee.<ccy> Revenue Credit (-) Financing fee charged to trading vaults for negative overnight balances.
asset.treasury_venue.<provider>.<ccy> Asset Debit (+) Company (house) cash at a provider. Kept apart from nostro/custody so safeguarding never counts house money as client cover.
asset.treasury_in_transit.<ccy> Asset Debit (+) Treasury transfers between venues, opened on initiation and cleared on completion.
expense.provider_fee.<ccy> Expense Debit (+) Fees the provider charges Depasify: per-payment provider cost and fees reported on fiat treasury transfers.
expense.network_gas.<ccy> Expense Debit (+) On-chain gas paid on treasury transfers and sweeps.
equity.manual_adjustment.<ccy> Equity Debit (+) Default counterpart of manual ledger entries. Offset can be switched to equity.opening_suspense via Book::Chart::MANUAL_OFFSET_BASES.
equity.opening_suspense.<ccy> Equity Debit (+) Balancing counterpart for legacy balances inherited upon cohort onboarding and for the entity opening. Finance clears it with manual journals.

Currencies & Precision

Book::Amount::SCALES sets 2 decimals for fiat (EUR, GBP, USD, CHF, CAD, PLN, HKD, BRL, MXN) and 10 decimals for crypto (USDC, USDT, BTC, ETH, SOL, POL, TRX, BNB, EURC, STRCX).

4. Worked Journal Examples

Representative transactions across all lifecycles showing exact lines, system keys, and sign conventions.

1. Fiat Pay-in: 10,000.00 EUR SEPA Deposit via OpenPayd fiat_payment.incoming.settled

Client receives funds via SEPA credit transfer. OpenPayd confirms receipt.

AccountKindCurrencyDebit (+)Credit (-)
nostro.open_payd.EUR Asset EUR 10,000.00 —
client_funds.101.EUR Liability EUR — -10,000.00

Key: fiat_payment.incoming.d82fa081 • Rule: Book::Rules::FiatPayin

2. Blockchain Pay-in: 100.5000000000 USDC Deposit via DFNS blockchain_payment.incoming.settled

Deposit confirmed on-chain to client's DFNS deposit address. Recorded with 10 decimal precision.

AccountKindCurrencyDebit (+)Credit (-)
custody.dfns.USDC Asset USDC 100.5000000000 —
client_funds.101.USDC Liability USDC — -100.5000000000

Key: blockchain_payment.incoming.9f4b11e2 • Rule: Book::Rules::BlockchainPayin

3. Blockchain Payout: 50.00 USDC Payout + 1.25 USDC Network Fee 3-Phase Lifecycle

Outgoing on-chain payment accepted, fee recognized, and settled when DFNS confirms.

Phase 1: Initiation (Accepted into transit buffer)

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.USDCLiabilityUSDC50.0000000000—
asset.crypto_payouts_in_transit.USDCAssetUSDC—-50.0000000000

Phase 2: Network Fee Charged

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.USDCLiabilityUSDC1.2500000000—
revenue.blockchain_fee.USDCRevenueUSDC—-1.2500000000

Phase 3: Settlement (DFNS confirms on-chain)

AccountKindCurrencyDebit (+)Credit (-)
asset.crypto_payouts_in_transit.USDCAssetUSDC50.0000000000—
custody.dfns.USDCAssetUSDC—-50.0000000000

Net effect: Transit account clears to 0. Client liability reduced by 51.25 USDC. DFNS custody credited 50.00 USDC.

Phase 4 (Alternative): Failure & Reversal (On-chain rejection)

If the transfer fails, both the payment and network fee ledger entries are destroyed. The book reverses both journals: the initiation reversal clears transit and restores 50.00 USDC, while the fee reversal reverses revenue.blockchain_fee and restores 1.25 USDC.

AccountKindCurrencyDebit (+)Credit (-)
asset.crypto_payouts_in_transit.USDCAssetUSDC50.0000000000—
client_funds.101.USDCLiabilityUSDC—-50.0000000000
revenue.blockchain_fee.USDCRevenueUSDC1.2500000000—
client_funds.101.USDCLiabilityUSDC—-1.2500000000

Net effect on failure: Transit, revenue, and client liability all return to 0.00.

4. Two-Step FX Trade: Buy 5.00 USDT with 50.00 EUR (Fee Split + Spread) trade.order.initiated & trade.settled

Client places market order. Pending leg debited. When filled, pending order reverses and settled trade posts with fee splits.

Step A: Order Placed (Initiated)

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR50.00—
fx_position.EURAssetEUR—-50.00

Step B: Order Filled (Reversal of initiated + Settled posting)

Reversal posted: Debits fx_position.EUR (+50.00), Credits client_funds.101.EUR (-50.00).

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR50.00—
revenue.trading_fee.EURRevenueEUR—-0.25
customer_collections.99.EURLiabilityEUR—-0.25
fx_position.EURAssetEUR—-49.50
fx_position.USDTAssetUSDT5.00—
client_funds.101.USDTLiabilityUSDT—-5.00

Step C: Spread Recognition

AccountKindCurrencyDebit (+)Credit (-)
fx_position.EURAssetEUR0.50—
revenue.fx_spread.EURRevenueEUR—-0.50
5. Circle Global Payout: 10.50 USDC Source to 9.50 EUR Payout circle_payment.fee & nostro.circle

Crypto-funded payout via Circle: charges fee, executes internal trade, books fiat payout into transit, and settles against Circle nostro upon completion webhook.

Phase 1: Circle Fee Charged (0.50 USDC)

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.USDCLiabilityUSDC0.50—
revenue.fiat_fee.USDCRevenueUSDC—-0.50

Phase 2: Internal Conversion Trade (10.00 USDC → 9.50 EUR)

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.USDCLiabilityUSDC10.00—
fx_position.USDCAssetUSDC—-10.00
fx_position.EURAssetEUR9.50—
client_funds.101.EURLiabilityEUR—-9.50

Phase 3: Outgoing Fiat Payout in Transit & Nostro Settlement (9.50 EUR)

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR9.50—
asset.payouts_in_transit.EURAssetEUR—-9.50
Webhook arrives: cpn.payment.completed
asset.payouts_in_transit.EURAssetEUR9.50—
nostro.circle.EURAssetEUR—-9.50
6. Overnight Liquidity Fee: 0.18 EUR Charged on Negative Balance overnight_liquidity_fee.charged

Automated cron charges financing fee for negative client balance maintained overnight.

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EUR Liability EUR 0.18 —
revenue.overnight_liquidity_fee.EUR Revenue EUR — -0.18

Key: overnight_liquidity_fee.44912 • Rule: Book::Rules::OvernightLiquidityFee

7. Card Pay-in & 4-Way Fee Split: 1,000.00 EUR via Worldpay card_payment.incoming.settled & card_payment.fees.charged

Merchant receives card payment. Acquirer receivable booked, client funds credited, and multi-leg fee revision posted with interchange and partner collections splits.

Step A: Card Pay-in Settled (1,000.00 EUR)

AccountKindCurrencyDebit (+)Credit (-)
asset.acquirer_receivable.worldpay.EURAssetEUR1,000.00—
client_funds.101.EURLiabilityEUR—-1,000.00

Rule: Book::Rules::CardPayin • Event: card_payment.incoming.settled

Step B: Multi-Leg Fee Split (25.00 EUR Blended Fee)

Client pays 25.00 EUR fee; 12.00 EUR IC/Scheme pass-through owed to Worldpay; 5.00 EUR partner collections share; 8.00 EUR net revenue retained.

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR25.00—
liability.acquirer_payable.worldpay.EURLiabilityEUR—-12.00
customer_collections.99.EURLiabilityEUR—-5.00
revenue.card_fee.EURRevenueEUR—-8.00

Rule: Book::Rules::CardFees • Event: card_payment.fees.charged (Idempotency: card_fees.<uuid>.1)

8. Rolling Reserve Collection: 50.00 EUR Card Collateral Held rolling_reserve.collected

Automated rolling reserve run holds 5% of weekly card volume for 180 days. Funds move from available client funds to customer rolling reserve liability.

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR50.00—
client_rolling_reserve.42.EURLiabilityEUR—-50.00

Key: rolling_reserve_collection.8421 • Rule: Book::Rules::RollingReserveCollected • Reversal on release: rolling_reserve.collected.reversed

9. Card Refund: 200.00 EUR Settled via Checkout.com card_payment.refund.settled

Card transaction refunded to cardholder. Reverses client funds liability and credits acquirer receivable.

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR200.00—
asset.acquirer_receivable.checkout.EURAssetEUR—-200.00

Key: fiat_payment.refund.<uuid> • Rule: Book::Rules::CardRefund

10. Admin Invoice Deduction & Account Restoration manual_entry.posted & AccountRestoration

Direct ledger operations for customer fee invoice deductions and unclosing accounts.

Step A: Admin Invoice Deduction (500.00 EUR from Main Account)

Staff executes invoice deduction from customer main account (/panel/invoice_deductions). Categorized as invoice.

AccountKindCurrencyDebit (+)Credit (-)
client_funds.101.EURLiabilityEUR500.00—
equity.manual_adjustment.EUREquityEUR—-500.00

Key: invoice_deduction:INV-2026-001 • Service: LedgerEntries::CreateInvoiceDeduction → Book::Pilot.post_manual_entry

Step B: Pre-Closure Balance Restoration on Account Unclose (250.50 EUR)

Staff restores a previously closed account (/panel/accounts/:id). Outgoing closure payments are aggregated and restored via AccountRestoration.

AccountKindCurrencyDebit (+)Credit (-)
equity.manual_adjustment.EUREquityEUR250.50—
client_funds.101.EURLiabilityEUR—-250.50

Service: Accounts::RestoreService → Book::Pilot.post_manual_entry

11. Provider Cost: 100.00 EUR Payout (2.00 EUR client fee) via OpenPayd fiat_payment.provider_cost

The provider charges on top: the payout costs 102 from the provider account. The cost is a separate journal from the payout, never touches a client account, and so leaves parity unaffected. Pay-ins book it with the pay-in; payouts book it at settlement. Here the rule is a fixed 0.35 EUR + 0.10% of volume = 0.45 EUR.

AccountKindCurrencyDebit (+)Credit (-)
expense.provider_fee.EURExpenseEUR0.45—
nostro.open_payd.EURAssetEUR—-0.45

Key: provider_cost.<payment id> • Rule: Book::Rules::FiatProviderCost • Amount: Book::ProviderCost

12. Treasury Transfer: 1,000.00 USDC Kraken → OpenPayd with 1.00 USDC Withdrawal Fee treasury_transfer.initiated / completed

Step A: Initiation. The whole amount leaves the source venue and sits in transit.

AccountKindCurrencyDebit (+)Credit (-)
asset.treasury_in_transit.USDCAssetUSDC1,000.0000000000—
asset.treasury_venue.kraken.USDCAssetUSDC—-1,000.0000000000

Rule: Book::Rules::TreasuryTransferInitiated

Step B: Completion. Kraken deducts its fee from the amount (deducted_fee), so the destination receives amount - fee and the fee is an expense (network gas on-chain, provider fee for fiat).

AccountKindCurrencyDebit (+)Credit (-)
asset.treasury_venue.open_payd.USDCAssetUSDC999.0000000000—
expense.network_gas.USDCExpenseUSDC1.0000000000—
asset.treasury_in_transit.USDCAssetUSDC—-1,000.0000000000

Rule: Book::Rules::TreasuryTransferCompleted • Routes crossing legal entities post nothing (see Section 6).

13. Manual Journal with Four-Eyes Approval and Reversal manual_journal.<id>

Finance requests a journal at /panel/manual_journals (entity, effective date, balanced lines on system accounts). The request is the first approval; a different admin approves and the journal posts in the same transaction. Example: clearing 120.00 EUR of opening suspense against house cash.

AccountKindCurrencyDebit (+)Credit (-)
asset.treasury_venue.open_payd.EURAssetEUR120.00—
equity.opening_suspense.EUREquityEUR—-120.00

A Reverse Journal action does not post directly: it requests a mirrored manual journal (reverses_journal_entry) that a second admin approves, so the reversal records both requester and approver.

Key: manual_journal.<id> • Only system-key accounts without an owner are allowed; client, collections and rolling-reserve accounts follow the legacy ledger and are refused.

5. Call Trees & Code Paths

Exact call sequences showing triggers, savepoint boundaries, and book rules.

A. Blockchain Payout Settlement & Failure Flow

[Api::Hooks::DfnsController / Confirmer / Reconciler / Withdraw::Executor]
├── If Confirmed: payment.update!(status: "confirmed" | "completed_withdraw", tx_hash:)
│   └── Book::Pilot.settle_blockchain_payment(payment)
│       ├── if !blockchain_in_scope? || !payment.kind.in?(%w[outgoing withdraw]) -> return
│       ├── Book::Pilot#initiate_blockchain_payout
│       │   ├── payment.association(:ledger_entry).reset [ActiveRecord in-memory cache drop]
│       │   ├── if payment.ledger_entry.nil? -> return :no_ledger_entry
│       │   ├── if entry.created_at <= opened_at -> return :pre_cut_over [covered by opening balance]
│       │   ├── if initiation_journal.present? -> reinstate and return :ok
│       │   └── else -> lazy post_blockchain_payment initiation first
│       └── in_savepoint
│           ├── Book::Rules::BlockchainPayoutSettled.call(payment:, chart:)
│           │   ├── Debit: chart.crypto_payouts_in_transit(currency) [+amount]
│           │   └── Credit: chart.custody(provider, currency) [-amount]
│           └── Book::Post.call(event: "blockchain_payment.outgoing.settled", idempotency_key:, lines:)
└── If Failed: payment.update!(status: "failed")
    ├── blockchain_payment_fee&.destroy [destroys legacy fee entry]
    ├── ledger_entry&.destroy [destroys legacy payment entry]
    └── Book::Pilot.reverse_blockchain_payment(payment)
        ├── reverse_blockchain_initiation [reverses transit leg back to client liability]
        └── reverse_blockchain_fee [reverses revenue.blockchain_fee back to client liability]

B. Two-Step Trade Fill Flow

CheckBit2meOrdersJob#check_order_status_recursively
├── Bit2me::Orders.show(trade.external_id) (HTTP GET)
├── trade.update!(status: "filled", price:, amount:, volume:)
├── trade.update_ledger_entries_after_execution
│   ├── Updates initial entry with uppercase currency
│   └── ledger.ledger_entries.create(amount: volume, currency: base.upcase, entryable: self)
└── Book::Pilot.settle_trade(trade)
    ├── if !trade_in_scope? -> return
    ├── in_savepoint
    │   ├── reverse_trade_journals(events: ["trade.order.initiated"], reason: "trade_settled")
    │   │   └── Book::Reversal.call(journal:, reason: "trade_settled")
    │   ├── lines = Book::Rules::TradeSettled.call(trade:, chart:)
    │   │   ├── Splits trading fee revenue vs customer collections
    │   │   └── Balances desk fx_position across both legs
    │   └── Book::Post.call(event: "trade.settled", idempotency_key:, lines:)
    └── if trade.trade_spread.present?
        └── Book::Pilot.post_trade_spread(trade.trade_spread)
            └── in_savepoint -> Book::Rules::TradeSpreadRecognized -> Book::Post.call

C. Card Payment & Multi-Currency Fee Sync Flow

CardsGateway::CreateFiatPayment / ProcessFailedPayments::SettlePayments
├── payment.update!(status: "completed", ...)
├── Book::Pilot.post_fiat_payment(payment) [posts card_payment.incoming.settled]
└── Book::Pilot.sync_card_fees(payment)
    ├── current = current_card_fee_journal(payment)
    ├── Book::Reversal.call(journal: current, reason: "card_fees_updated") if live?(current)
    ├── revision = next_card_fee_revision(payment)
    ├── lines = Book::Rules::CardFees.call(payment:, chart:)
    │   ├── Grouped by currency (e.g. fixed fee in EUR + volume fee in USD)
    │   ├── Client total debit: -SUM(client_fees)
    │   ├── Pass-through credit: liability.acquirer_payable.<acquirer> (IC + Scheme fees)
    │   ├── Collections credit: customer_collections.<id>
    │   └── Revenue credit: revenue.card_fee (client_total - pass_through + collections)
    └── Book::Post.call(event: "card_payment.fees.charged", idempotency_key: "card_fees.#{uuid}.#{revision}", lines:)

D. Authoritative Balance Resolution Pipeline

FetchBalancesFromAccounts#balance_rows
├── legacy_ids = all_account_ids - authoritative_ids
├── authoritative_ids = active_cohort_accounts.select { |a| Book::PostingMode.authoritative_member?(a) }
├── legacy_rows = Account.where(id: legacy_ids).joins(ledger: :ledger_entries).group(:id, :currency)...
└── authoritative_rows:
    ├── AccountBalance.joins(:ledger_account)
    │   .where(ledger_accounts: { owner_type: "Account", owner_id: authoritative_ids })
    │   .where("system_key LIKE 'client_funds.%' OR system_key LIKE 'customer_collections.%'")
    │   .group(:owner_id, :currency)
    └── map { |id, ccy, posted| [id, ccy, BigDecimal("0") - posted.to_d] } [Inverts liability to client balance]

E. Admin Invoice Deduction & Account Restoration Flow

[Admin::InvoiceDeductionsController / Accounts::RestoreService]
├── Invoice Deduction:
│   └── LedgerEntries::CreateInvoiceDeduction.call(customer:, actor:, amount:, ticket:)
│       ├── ledger.lock!
│       ├── check idempotency: invoice_deduction:<TICKET>
│       ├── ledger.ledger_entries.create!(amount: -value, category: "invoice", metadata: ...)
│       └── Book::Pilot.post_manual_entry(entry)
│           └── Book::Rules::ManualAdjustment.call
│               ├── Debit: chart.client_liability(account, currency) [+value]
│               └── Credit: chart.manual_adjustment(currency) [-value]
└── Account Restore:
    └── Accounts::RestoreService#restore_closure_balance
        ├── closure_totals = FiatPayment.with_deleted.where(kind: "outgoing").where("description LIKE '%closure%'").group(:currency).sum(:amount)
        └── closure_totals.each do |currency, amount|
            ├── entry = ledger.ledger_entries.create!(amount: amount, category: "AccountRestoration")
            └── Book::Pilot.post_manual_entry(entry)
                ├── Debit: chart.manual_adjustment(currency) [+amount]
                └── Credit: chart.client_liability(account, currency) [-amount]

F. Provider Cost Booking

Book::Pilot (pay-in booked | payout settled)
└── Book::ProviderCost.call(payment)
    ├── nil unless card-free, non-internal fiat bank rail in a currency with a rule
    ├── nil if payment.created_at <= legal_entity.book_provider_cost_started_at
    ├── provider = settlement provider (OpenPayd-rejected, BBVA-paid payout => bbva)
    ├── rule = CustomerBaseCost global rule, exact rail + provider + currency [enabled only]
    │   └── Faster Payments (faster_payments) uses the fps rule
    ├── amount = fixed + percentage of payment (volume rules only)
    └── Book::Rules::FiatProviderCost
        ├── Debit:  expense.provider_fee.<ccy>   [+amount]
        └── Credit: nostro.<provider>.<ccy>     [-amount]
    └── Book::Post.call(event: "fiat_payment.provider_cost", idempotency_key: "provider_cost.#{payment.id}")
Reversal/restore of the payment reverses/reinstates the cost journal with it.

G. Treasury Transfer & Fee Flow

TreasuryOperation (initiated)
├── Book::TreasuryEntity: source/destination venue entity
│   ├── different entities (e.g. OpenPayd UK = plenifi) -> post nothing, listed in MissingPostings#deferred_intercompany
│   └── same entity -> Book::Rules::TreasuryTransferInitiated
│       ├── Debit:  asset.treasury_in_transit.<ccy>
│       └── Credit: asset.treasury_venue.<source>.<ccy>
└── (completed)
    └── Book::Rules::TreasuryTransferCompleted
        ├── Debit:  asset.treasury_venue.<destination>.<ccy>  [amount - deducted_fee]
        ├── Debit:  expense.network_gas | expense.provider_fee    [deducted_fee]
        └── Credit: asset.treasury_in_transit.<ccy>            [amount]

H. Manual Journal Four-Eyes Approval

Panel: ManualJournal (request)           [requester = first approval, FourEyesApprovable]
└── Second admin: approve
    └── transaction
        ├── validate lines again (whitelisted accounts, date <= today)
        ├── Book::Post.call(source: manual_journal, idempotency_key: "manual_journal.#{id}")
        └── if the book refuses (closed period) -> approval rolled back
Panel: Reverse Journal (admin, reason) -> new ManualJournal(reverses_journal_entry:) -> same approval path

I. Nostro Reconciliation

ProviderBalanceSnapshot commit
└── Book::NostroReconciliationJob.perform_later(snapshot.id)   [enqueued after commit]
    └── Book::NostroReconciliation.call(snapshot:)
        ├── book = nostro + custody + treasury venue accounts of provider, as of snapshot time
        ├── in_transit = accepted (sent | confirmed) open fiat payouts of that provider
        ├── difference = book - actual - in_transit
        ├── insert immutable nostro_reconciliations row
        └── if |difference| > tolerance and BOOK_NOSTRO_ALERTS=true -> Book::Alerts (one per entity, top 10)

6. Key Mechanics & Edge Cases

Non-obvious concurrency controls and data consistency safeguards.

Mechanic Problem Addressed Implementation Detail
Association Cache Reset When a batched payout fails, its legacy entry is destroyed. If the payout stays in memory, payment.ledger_entry returns the cached destroyed record. payment.association(:ledger_entry).reset is called before checking existence in ensure_ledger_entry.
Pessimistic Deterministic Locking Concurrent postings updating account_balances risk deadlocks if accounts are locked in arbitrary order. Book::Post#apply_balances sorts account IDs deterministically before acquiring row locks.
Mixed-Case Index Preservation External providers supply inconsistent casing (e.g. STRCx vs STRCX). Querying LOWER(currency) invalidates B-tree indexes. Ledger#currency_variants supplies exact equality array [currency, currency.upcase, currency.downcase].
Chained Reversal Sequences A payment that fails, retries, and fails again cannot reuse the same reversal idempotency key. Sequences keys as reversal.#{journal.id}.#{count + 1}. Reinstating reverses the reversal.
Lazy Initiation on Settlement If initiation failed open during payout creation, settlement would clear transit for a debit that was never booked. initiate_blockchain_payout posts initiation first before settling transit.
Card Fee Revision Sequencing Card fee recalculations or late vendor fee distributions alter fee breakdowns after the payment is booked. sync_card_fees looks up existing fee journals, issues Book::Reversal, and posts revision card_fees.#{uuid}.#{rev} with snapshotted delta baselines.
Authoritative Dual-Run Reading Zero-downtime transition from legacy ledger to book without data loss or dual-write divergence. Book::PostingMode routes reads to projected account_balances (inverting sign 0 - posted) while continuous shadow dual-writing permits instant zero-migration rollback.
Multi-Currency Card Fixed Fees Fixed per-transaction gateway fees are billed in EUR even when underlying card transactions settle in USD or GBP. Book::Rules::CardFees groups fee entries by currency and builds balanced journal lines independently per currency.
Vault Enrollment & Privileged Roles Bulk onboarding must enroll collections accounts with markup cut-overs while keeping internal profit accounts excluded. Book::VaultEnrollment clears legacy account overrides, enrolls collections accounts with opening markup journals, and excludes accounts with role profits.
Failed Crypto Fee & Payment Reversal Network fees on failed crypto payouts previously survived in the legacy ledger while payouts were deleted, risking orphaned fee journals. BlockchainPayment#update_ledger_entry deletes both the fee and payment entries on failure; Book::Pilot#reverse_blockchain_payment and #reverse_blockchain_fee cleanly reverse both the initiation and fee revenue journals.
Covering Index for Dual-Run Parity Real-time parity checks and daily reconciliation jobs scan millions of rows in ledger_entries, risking slow queries and table contention. Added PostgreSQL covering index on (ledger_id, currency) INCLUDE (amount) WHERE deleted_at IS NULL enabling index-only scans for balance aggregations.
Pre-Closure Balance Reconstruction Unclosing an account leaves its balance at zero after closure payouts drained funds, leaving client liabilities unrepresented. Accounts::RestoreService idempotently sums historical closure payouts and books a reversal against equity.manual_adjustment via Book::Pilot.post_manual_entry.
Provider Cost Rule Hygiene Provider fees must follow commercial rules without producing false missing postings or mixing customer terms into bank fees. Book::ProviderCost reads only the global rule for the exact rail, provider and currency (customer overrides and all-rails baselines are ignored). Only payments after book_provider_cost_started_at are costed, payouts are dated at settlement, and MissingPostings skips payments older than a rule's updated_at.
House vs Client Money Company treasury cash must not inflate client safeguarding cover. House cash lives on asset.treasury_venue.*; nostro/custody hold only the client part. Acquirer receivables are reported but not counted as safeguarded assets.
Entity Opening Provider cash must enter the book once, with house money separated from client money. Book::EntityOpeningBalance (rake book:entities:open[code]) posts provider snapshot balances nearest the opening time (within one day); the client part is what enrolled accounts are owed, capped at the snapshot; the remainder goes to asset.treasury_venue and the difference to equity.opening_suspense. One live opening per entity (legal_entities.book_opened_at); reversible with book:entities:reverse_opening.
Batch Entity Enrollment Enrolling thousands of accounts must not be all-or-nothing. rake book:cohort:add_entity[code,mode,after_vault_id] enrolls vault by vault, one transaction each. Refused vaults are rolled back whole and reported; the run is resumable.
Cross-Entity Treasury Routes A transfer between venues owned by different entities (OpenPayd UK is Plenifi) needs intercompany accounting, which does not exist yet. Book::TreasuryEntity resolves the owning entity. Such routes post nothing and surface in MissingPostings#deferred_intercompany instead of alerting.
Treasury Fee Capture Kraken deducts its withdrawal fee from the amount. The quoted fee is stored as deducted_fee; completion delivers amount - fee and books the fee as gas (on-chain) or provider fee (fiat). Providers charging on top (OpenPayd, DFNS, B2C2, Binance) report nothing bookable, so those fees are not in the book.
Period Close & Re-dating Late events for a closed month must not break the closed books. Book::PeriodClose is a lock with a checklist (each statement under a 20 s timeout). Redated events keep their value date, move to the next open period and record the original in metadata. Reopening needs a reason and no later closed period, and is audited. Nostro reconciliation breaks appear only as warnings.
Manual Journal Four-Eyes Hand-written journals need control and must not break client parity. Request and approval by different admins; only system accounts without owner; effective date not in the future; lines revalidated at approval; reversal is itself a four-eyes manual journal.
In-Transit Ageing Pooled in-transit accounts hide which item is stuck. Book::InTransitAgeing nets each source (payout, transfer) per account family using its open sign (treasury debit, payouts credit). Sources not netting to zero after 3 days, or on the wrong side at any age, are flagged.
Non-Blocking Alerts Alerting must never slow or fail the payment path or spam channels. Book::Alerts logs, emits Telemetry, and enqueues Book::SlackAlertJob; throttled to one per title and entity per 10 minutes.

7. Parity & Daily Reconciliation

The book runs alongside the legacy ledger. Parity verifies that the book liability exactly mirrors the legacy client balance. Checks are set-based (queries scale with batches of 500 accounts, not accounts) and alerts are sent asynchronously per entity.

The Balance Parity Formula

Legacy Post-Cutover Entries + Legacy Opening Snapshot + Book Client Liability = 0
Daily Automated Job Schedule Check Performed
Book::ParityJob 07:15 UTC Compares legacy ledger vs book journal lines per account & currency. Alerts if delta != 0.
Book::MissingPostingsJob 07:25 UTC Scans covered legacy entries created after opened_at lacking a journal entry.
Book::BalanceDriftJob 07:35 UTC Compares materialized AccountBalance table vs sum of raw JournalLine records.
Book::SafeguardingJob 07:45 Per active entity: safeguarding coverage and the book.suspense.balance gauge. Ignores the part of a liability still awaiting the card acquirer.
Book::InTransitAgeingJob 07:55 Flags in-transit sources that do not net to zero after 3 days or sit on the wrong side.
Book::NostroReconciliationJob After each provider snapshot Compares book vs provider balance: book - actual - in transit. Immutable rows; alerts only when BOOK_NOSTRO_ALERTS=true (off by default while provider cost and the LP leg are incomplete).

Regulatory Safeguarding & Financial Statements

In addition to client parity, the engine derives regulatory reporting directly from the immutable double-entry core:

Regulatory Safeguarding (Book::Safeguarding)

Verifies that client liabilities are 100%+ covered by segregated safeguarding assets under EMI regulations.

Surplus = (Nostro + Custody + Transit + FX Position) - (Client Funds + Collections + Rolling Reserve - Opening Suspense)
  • Reports real-time surplus/deficit per legal entity and currency.
  • Acquirer receivables are shown but not counted as safeguarded; treasury venue (house) cash is excluded and the opening suspense offset leaves out the house part.
  • Compares book asset balances against live external provider snapshots (OpenPayd, BBVA, DFNS, Kraken) via Book::ProviderAttribution; a provider shared by two entities is attributed to neither.

Signed Trial Balance (Book::TrialBalance)

Formal accounting trial balance accessible in panel at /panel/book_trial_balance.

  • Groups accounts by kind: Asset, Liability, Equity, Revenue, Expense.
  • Reads from projected account_balances for current state or reconstructs historical state as_of date from journal_lines.
  • Strict invariant: Total Debit + Total Credit = 0 across every currency.

ActiveAdmin Book Operations Suite (/panel)

The ActiveAdmin backoffice provides full operational visibility and administration for The Book:

Panel Endpoint Resource / Title Operational Capabilities
/panel/book_cohorts Book Cohorts Manage onboarding cohorts, view enrolled vaults and accounts, toggle posting modes (shadow/authoritative), and safely remove accounts (retaining historical journals).
/panel/journal_entries Journal Entries Audit immutable journal lines, filter by event type, legal entity, period, and idempotency key. Direct create/update/destroy actions strictly denied by Pundit.
/panel/ledger_accounts Ledger Accounts Inspect multi-currency chart of accounts, inspect real-time Posted, Held, and Available balances, and export comprehensive statement CSVs.
/panel/book_trial_balance Book Trial Balance Multi-entity, multi-currency signed trial balance with safeguarding surplus/deficit summary tags (Covered / Shortfall) and full CSV trial balance download.
/panel/accounting_periods Book Periods Close checklist, close and reopen (reason required, audited). Shows last nostro reconciliation per provider and currency as a warning.
/panel/manual_journals Book Manual Journals Request, approve (different admin) and reject manual journals; panel reversal as a four-eyes mirror journal.
/panel/nostro_reconciliations Book Nostro Reconciliation Latest row per provider and currency, how long it has been broken, link to the nostro statement.
Clients → Provider Costs Provider Costs Finance grid per provider and currency, one row per rail with fixed amount and percentage; empty row disables the rule. Versioned with PaperTrail. Customer overrides stay in Base Costs.
/panel/book_trial_balance → Opening Suspense Opening Suspense Suspense per currency split into client openings, provider cash and other (manual journals, clearing).

Known Limitations

8. Interactive Simulator

Select a transaction template, adjust values, and inspect the balanced double-entry lines and T-accounts.