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.
+100 EUR). No counterpart leg records where the money is kept.destroy or update(amount: 0), erasing audit history.JournalEntry with ≥ 2 lines summing to zero per currency.Book::Pilot and Book::Post.BigDecimal.
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 |
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. |
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;
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. |
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).
Representative transactions across all lifecycles showing exact lines, system keys, and sign conventions.
fiat_payment.incoming.settled
Client receives funds via SEPA credit transfer. OpenPayd confirms receipt.
| Account | Kind | Currency | Debit (+) | 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
blockchain_payment.incoming.settled
Deposit confirmed on-chain to client's DFNS deposit address. Recorded with 10 decimal precision.
| Account | Kind | Currency | Debit (+) | 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-Phase Lifecycle
Outgoing on-chain payment accepted, fee recognized, and settled when DFNS confirms.
Phase 1: Initiation (Accepted into transit buffer)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.USDC | Liability | USDC | 50.0000000000 | — |
asset.crypto_payouts_in_transit.USDC | Asset | USDC | — | -50.0000000000 |
Phase 2: Network Fee Charged
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.USDC | Liability | USDC | 1.2500000000 | — |
revenue.blockchain_fee.USDC | Revenue | USDC | — | -1.2500000000 |
Phase 3: Settlement (DFNS confirms on-chain)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.crypto_payouts_in_transit.USDC | Asset | USDC | 50.0000000000 | — |
custody.dfns.USDC | Asset | USDC | — | -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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.crypto_payouts_in_transit.USDC | Asset | USDC | 50.0000000000 | — |
client_funds.101.USDC | Liability | USDC | — | -50.0000000000 |
revenue.blockchain_fee.USDC | Revenue | USDC | 1.2500000000 | — |
client_funds.101.USDC | Liability | USDC | — | -1.2500000000 |
Net effect on failure: Transit, revenue, and client liability all return to 0.00.
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)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 50.00 | — |
fx_position.EUR | Asset | EUR | — | -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).
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 50.00 | — |
revenue.trading_fee.EUR | Revenue | EUR | — | -0.25 |
customer_collections.99.EUR | Liability | EUR | — | -0.25 |
fx_position.EUR | Asset | EUR | — | -49.50 |
fx_position.USDT | Asset | USDT | 5.00 | — |
client_funds.101.USDT | Liability | USDT | — | -5.00 |
Step C: Spread Recognition
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
fx_position.EUR | Asset | EUR | 0.50 | — |
revenue.fx_spread.EUR | Revenue | EUR | — | -0.50 |
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)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.USDC | Liability | USDC | 0.50 | — |
revenue.fiat_fee.USDC | Revenue | USDC | — | -0.50 |
Phase 2: Internal Conversion Trade (10.00 USDC → 9.50 EUR)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.USDC | Liability | USDC | 10.00 | — |
fx_position.USDC | Asset | USDC | — | -10.00 |
fx_position.EUR | Asset | EUR | 9.50 | — |
client_funds.101.EUR | Liability | EUR | — | -9.50 |
Phase 3: Outgoing Fiat Payout in Transit & Nostro Settlement (9.50 EUR)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 9.50 | — |
asset.payouts_in_transit.EUR | Asset | EUR | — | -9.50 |
| Webhook arrives: cpn.payment.completed | ||||
asset.payouts_in_transit.EUR | Asset | EUR | 9.50 | — |
nostro.circle.EUR | Asset | EUR | — | -9.50 |
overnight_liquidity_fee.charged
Automated cron charges financing fee for negative client balance maintained overnight.
| Account | Kind | Currency | Debit (+) | 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
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)
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.acquirer_receivable.worldpay.EUR | Asset | EUR | 1,000.00 | — |
client_funds.101.EUR | Liability | EUR | — | -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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 25.00 | — |
liability.acquirer_payable.worldpay.EUR | Liability | EUR | — | -12.00 |
customer_collections.99.EUR | Liability | EUR | — | -5.00 |
revenue.card_fee.EUR | Revenue | EUR | — | -8.00 |
Rule: Book::Rules::CardFees • Event: card_payment.fees.charged (Idempotency: card_fees.<uuid>.1)
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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 50.00 | — |
client_rolling_reserve.42.EUR | Liability | EUR | — | -50.00 |
Key: rolling_reserve_collection.8421 • Rule: Book::Rules::RollingReserveCollected • Reversal on release: rolling_reserve.collected.reversed
card_payment.refund.settled
Card transaction refunded to cardholder. Reverses client funds liability and credits acquirer receivable.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 200.00 | — |
asset.acquirer_receivable.checkout.EUR | Asset | EUR | — | -200.00 |
Key: fiat_payment.refund.<uuid> • Rule: Book::Rules::CardRefund
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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
client_funds.101.EUR | Liability | EUR | 500.00 | — |
equity.manual_adjustment.EUR | Equity | EUR | — | -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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
equity.manual_adjustment.EUR | Equity | EUR | 250.50 | — |
client_funds.101.EUR | Liability | EUR | — | -250.50 |
Service: Accounts::RestoreService → Book::Pilot.post_manual_entry
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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
expense.provider_fee.EUR | Expense | EUR | 0.45 | — |
nostro.open_payd.EUR | Asset | EUR | — | -0.45 |
Key: provider_cost.<payment id> • Rule: Book::Rules::FiatProviderCost • Amount: Book::ProviderCost
treasury_transfer.initiated / completed
Step A: Initiation. The whole amount leaves the source venue and sits in transit.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.treasury_in_transit.USDC | Asset | USDC | 1,000.0000000000 | — |
asset.treasury_venue.kraken.USDC | Asset | USDC | — | -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).
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.treasury_venue.open_payd.USDC | Asset | USDC | 999.0000000000 | — |
expense.network_gas.USDC | Expense | USDC | 1.0000000000 | — |
asset.treasury_in_transit.USDC | Asset | USDC | — | -1,000.0000000000 |
Rule: Book::Rules::TreasuryTransferCompleted • Routes crossing legal entities post nothing (see Section 6).
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.
| Account | Kind | Currency | Debit (+) | Credit (-) |
|---|---|---|---|---|
asset.treasury_venue.open_payd.EUR | Asset | EUR | 120.00 | — |
equity.opening_suspense.EUR | Equity | EUR | — | -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.
Exact call sequences showing triggers, savepoint boundaries, and book rules.
[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]
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
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:)
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]
[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]
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.
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]
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
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)
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. |
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). |
In addition to client parity, the engine derives regulatory reporting directly from the immutable double-entry core:
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)
Book::ProviderAttribution; a provider shared by two entities is attributed to neither.
Formal accounting trial balance accessible in panel at /panel/book_trial_balance.
account_balances for current state or reconstructs historical state as_of date from journal_lines.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). |
asset.acquirer_receivable.* is never cleared into a nostro.Book::Holds has no production caller yet (trades debit the client at order time in shadow mode).Select a transaction template, adjust values, and inspect the balanced double-entry lines and T-accounts.