Posting engine
The double-entry core. Small, pure, testable, source-agnostic.
The contract
Every Transaction gets ONE call to record_transaction(). It:
- Checks idempotency on
(source, source_txn_id). Duplicate → short-circuit and returnidempotent=True. - Inserts the
Transactionrow. - Dispatches to the posting rule for that
txn_type. - Inserts the returned journal lines.
- Wraps in one DB transaction.
result = await record_transaction(
session,
TransactionPayload(
source="VIEWTRADE",
source_txn_id="ORDER_12345",
customer_id="cust-abc",
txn_date=date.today(),
txn_type="buy_security",
currency="USD",
amount_native=Decimal("1500.00"),
amount_usd=Decimal("1500.00"),
symbol="AAPL",
isin="US0378331005",
qty=Decimal("10"),
),
commit=False,
)
Posting rules
One pure function per txn_type, in services/posting.py. Returns a list of
PostingLines. Wallet debits are scoped to customer_id; the customer-cash-
wallet account (2010 / 2000) is per-customer partitioned.
| txn_type | Debit | Credit | When |
|---|---|---|---|
wire_in | 1100 Omnibus cash | 2010 Customer wallet | Broker confirms deposit landed |
wire_out | 2010 | 1100 | Broker confirms withdrawal sent |
buy_security | 1200 custody + 2010 wallet | 2100 sec liability + 2010 wallet | Executed order (bundled fees) |
sell_security | 2100 + 2010 | 1100 + 2010 | Executed sell (bundled fees) |
brokerage | 2010 | 4000 (placeholder → recost splits) | Per trade |
taf_sec_fee | 2220 payable | 4020 revenue, 2010 wallet | US regulatory fee |
dividend | 1100 | 2010 | Broker credits dividend (gross) |
wht_tax | 2010 | 2200 payable | Withheld on dividend |
fx_spread | 1300 GlomoPay FX Receivable | 4030 FX Spread Revenue | GlomoPay status=paid (India) |
fx_spread_settle | 1010 operating bank | 1300 | GlomoPay settlement batch pays out |
deposit_in_transit | 1500 Cash-in-Transit | 2010 | Optional GL path |
deposit_settle | 1100 | 1500 | Clearing the 1500 leg |
reversal | mirror of the reversed txn | mirror | Refund / correction |
Balance invariant
Every posting rule MUST return lines where Σ debits = Σ credits in USD. The
insert enforces it — a non-balancing rule fails with a PostingError.
The trial balance check (Category A, trial_balance_zero) sums every journal
line across every account and asserts ΣDR = ΣCR on every reconciliation
cycle.
Idempotency
Unique constraint: (source, source_txn_id). A duplicate insert triggers the
short-circuit: record_transaction returns {txn_id: <existing>, idempotent: True} without creating anything.
For events that need multiple postings from the same upstream record (e.g. a
GlomoPay order that produces both an fx_spread accrual and an
fx_spread_settle clearing), the importer manufactures distinct
source_txn_ids (e.g. "ORDER_x" and "ORDER_x:fxsettle") so both stay
idempotent independently.
Reversal
services/reversal.py::reverse_transaction(txn_id) posts a new Transaction
with txn_type="reversal" and reverses_txn_id=<original>. It mirrors the
ACTUAL posted journal lines rather than re-running the poster — correct even
for recosted fees, which carry the real broker split rather than the placeholder
legs.
The reversal is itself idempotent: a second reverse_transaction(txn_id)
returns idempotent=True because the derived source_txn_id ({original}-REV)
already exists.
Customer wallet derivation
Each customer's wallet balance = Σ 2010 journal lines for that customer. The
wallet_derivation check (Category B) recomputes this from the journal and
asserts equality with whatever cached view the app is serving.