Appearance
Accounting Flow
Double-entry bookkeeping: journals + ledger entries generated per transaction category.
Overview
| Attribute | Value |
|---|---|
| Called by | OrchestratorService (real-time) and bulk processor services |
| Service | src/service/accounting/AccountingService.ts |
| Interface | src/repository/IAccountingService.ts |
| Journals storage | DynamoDB JournalsTable |
| Entries storage | DynamoDB LedgerEntriesTable → AccountUpdateQueue → AccountService.accountUpdater() |
accountingPreparer() is the single entry point. It routes to a category-specific preparer that fetches accounts, generates journals + ledger entries, validates double-entry balance, and persists everything.
Options (IAccountingPreparerOptions):
| Option | Default | Description |
|---|---|---|
skipModuleCheck | false | Skip the isLedgerEnabled check. Used by bulk/settlement flows. |
skipAccountUpdate | false | Skip sending entries to AccountUpdateQueue. Used by BulkReprocessProcessorService to aggregate entries per account before sending. |
Return type (IAccountingPreparerResult): { journals: IJournal[], entries: ILedgerEntry[], accounts: IAccount[] } — allows callers to handle entries themselves when skipAccountUpdate is true.
Flow Diagram
Category Preparers
Transaction Preparer (PAYMENT, REFUND, DISPUTE, VOID, WITHDRAWAL, TOPUP)
Generates an IN/OUT journal pair. Accounts fetched depend on the transaction type:
PAYMENT / REFUND / DISPUTE (default):
| Account | PK pattern | SK |
|---|---|---|
acqReceivableAccount | ACQ#{acquirer}#CUR#{currency} | ACCT#ACQUIRER_RECEIVABLE |
acqReserveReceivableAccount | ACQ#{acquirer}#CUR#{currency} | ACCT#RESERVE_RECEIVABLE |
businessPayableAccount | ENT#{entity_id}#CUR#{currency} | ACCT#BUSINESS_PAYABLE |
businessReservePayableAccount | ENT#{entity_id}#CUR#{currency} | ACCT#RESERVE_PAYABLE |
platformClearingAccount | ENT#T1#CUR#{currency} | ACCT#PLATFORM_CLEARING |
processingFeesRevenueAccount | ENT#T1#CUR#{currency} | ACCT#PROCESSING_FEES_REVENUE |
vatPayableAccount | ENT#T1#CUR#{currency} | ACCT#VAT_PAYABLE |
feesExpenseFeeAccount | ENT#T1#CUR#{currency} | ACCT#ACQUIRER_FEES_EXPENSE |
vatReceivableAccount | ENT#T1#CUR#{currency} | ACCT#VAT_RECEIVABLE |
VOID:
| Account | PK pattern | SK |
|---|---|---|
acqReceivableAccount | ACQ#{acquirer}#CUR#{currency} | ACCT#ACQUIRER_RECEIVABLE |
businessPayableAccount | ENT#{entity_id}#CUR#{currency} | ACCT#BUSINESS_PAYABLE |
platformClearingAccount | ENT#T1#CUR#{currency} | ACCT#PLATFORM_CLEARING |
WITHDRAWAL / TOPUP:
| Account | PK pattern | SK |
|---|---|---|
acqWithdrawalFundsAccount | ACQ#{acquirer}#CUR#{currency} | ACCT#WITHDRAWAL_FUNDS |
businessWithdrawalPayableAccount | ENT#{entity_id}#CUR#{currency} | ACCT#BUSINESS_WITHDRAWAL_PAYABLE |
businessPayableAccount | ENT#{entity_id}#CUR#{currency} | ACCT#BUSINESS_PAYABLE |
platformClearingAccount | ENT#T1#CUR#{currency} | ACCT#PLATFORM_CLEARING |
withdrawalFeesRevenueAccount | ENT#T1#CUR#{currency} | ACCT#WITHDRAWAL_FEES_REVENUE |
vatPayableAccount | ENT#T1#CUR#{currency} | ACCT#VAT_PAYABLE |
withdrawalFeesExpenseFeeAccount | ENT#T1#CUR#{currency} | ACCT#WITHDRAWAL_ACQUIRER_FEES_EXPENSE |
vatReceivableAccount | ENT#T1#CUR#{currency} | ACCT#VAT_RECEIVABLE |
acqFeesPayableAccount | ACQ#{acquirer}#CUR#{currency} | ACCT#WITHDRAWAL_ACQUIRER_FEES_PAYABLE |
Adjustment Preparer (ADJUSTMENT)
- Fetches target account from
record.account.PK/record.account.SK - Fetches contra account from
record.contra_account_number(or resolved via AdjustmentRules) - Generates a single journal (IN if acquirer account, OUT otherwise)
gross_amount = record.gross_amount— no fees
Settlement Preparer (SETTLEMENT_APPROVE, SETTLEMENT_CONFIRM)
Accounts depend on settlement_type:
| settlement_type | Accounts |
|---|---|
in_approve | acqReceivableAccount, bankAccount |
out_approve | businessPayableAccount, businessSettlementPendingAccount, processingFeesRevenueAccount, vatPayableAccount |
out_confirm | bankAccount, businessSettlementPendingAccount |
Single journal generated. type = IN if in_approve, else OUT.
Routing Preparer (ROUTING)
Accounts: businessPayableAccount, processingFeesRevenueAccount, vatPayableAccount (all platform T1).
Single journal, type = OUT, category = ROUTING.
Rolling Reserve Preparer (ROLLING_RESERVE_RELEASE)
Account selection depends on original_journal.type:
type = IN→acqReserveReceivableAccount,bankAccounttype = OUT→businessReservePayableAccount,businessPayableAccount,processingFeesRevenueAccount,vatPayableAccount
Single journal. Links back to original via related_journal_id = original_journal.id.
Journal Structure
All journals share these core fields:
| Field | Value | Notes |
|---|---|---|
id | UUID v7 | Unique per journal |
type | IN or OUT | |
category | JournalCategoryEnum | PAYMENT, REFUND, SETTLEMENT_APPROVE, etc. |
process_id | Transaction/settlement ID | Used for dedup via process_id-index |
entity_id | Business ID | |
currency_code | ISO currency | |
gross_amount | Original transaction amount | |
fee_amount | Total fees | |
iva_amount | IVA on fees | |
net_amount | Net after fees | |
rr_amount | Rolling reserve held | PAYMENT only |
acquirer | Acquirer identifier | |
provider | Provider identifier | |
process_date | Original transaction timestamp | Preserved in bulk — not Date.now() |
created_at | Original transaction timestamp (= process_date when valid, else nowMillis()) | Use this to query "when did this transaction happen?" |
generated_at | When our system created this record | Always nowMillis() at the moment of generation |
modified_at | When the record was last modified | Initially equal to generated_at; updated later by accountUpdater for ledger entries |
related_journal_id | ID of paired journal | IN links to OUT and vice versa |
settlement_id | Settlement ID | Set later by settlementJournalUpdater |
expected_reserve_release_date | Release date epoch ms | PAYMENT only |
description | Human-readable description | Template per category/type |
metadata | Fee rules + extra context |
created_at and generated_at semantics
Each journal and ledger entry carries two distinct timestamp fields:
| Field | Meaning | Value |
|---|---|---|
created_at | When the underlying business event happened (the original transaction date) | process_date when it is a valid epoch-ms number, otherwise nowMillis() |
generated_at | When our system created this record | Always nowMillis() at the moment of generation |
modified_at | When the record was last modified by our system | Initially equal to generated_at; updated by accountUpdater to its own nowMillis() when persisting ledger entries |
process_date | Original transaction timestamp from the source event | Preserved as-is (kept for clarity, even though created_at now equals it for valid inputs) |
The validation for created_at lives in resolveCreatedAt (src/utils/date.ts). It rejects undefined, null, NaN, Infinity, 0, and negative numbers — falling back to nowMillis() only when the source's process_date is unusable.
Use
created_atto query "when did this transaction happen?" andgenerated_atto query "when did we ingest this record?". In real-time flows the two values are nearly identical (separated by milliseconds). In bulk reprocessing they diverge:created_atkeeps the original transaction date andgenerated_atshows the bulk run time.
Rolling Reserve
Only applies to PAYMENT category:
expected_reserve_release_date =
processDate + hold_reserve_period_days × 86_400_000ms
(set to 00:00:00 UTC of that day)
rr_amount = fees.tonder.rolling_reserve_amounthold_reserve_period_days comes from the matched fee rule (IN side).
Ledger Entry Persistence
When ledgerEnabled = true:
generateEntriesByCategory()delegates toEntriesGeneratorFactory.createGenerator(category)— returns a category-specific generator that produces DEBIT/CREDIT entry pairs.validateBalance()sums all debits and credits; logs a warning if they don't match (does not throw).- Entries are grouped by
account_id. - SQS messages sent to
AccountUpdateQueue(FIFO) in batches of 10,MessageGroupId = account.id.
Message structure:
json
{
"process_id": "...",
"account": { "PK": "...", "SK": "..." },
"entries": [{ "id": "...", "type": "DEBIT|CREDIT", "amount": 0, ... }]
}AccountService.accountUpdater() applies entries sequentially per account, maintaining a running balance and annotating each entry with balance_account_before, balance_account_after, and seq.
Ledger Entry Structure
| Field | Description |
|---|---|
id | UUID |
account_id | Account UUID |
journal_id | Parent journal ID |
type | DEBIT or CREDIT |
amount | Entry amount |
process_date | Original transaction date (preserved) |
process_id | Transaction ID |
entity_id | Business ID |
currency_code | ISO currency |
balance_account_before | Account balance before this entry |
balance_account_after | Account balance after this entry |
seq | Sequential number within the account |
ttl_expiration | Epoch seconds — TTL set to 15 days after creation |
Account Balance Rules
AccountService.calculateDeltaByAccountType() determines the sign of each entry:
| Account type | DEBIT | CREDIT |
|---|---|---|
ASSET | + (increase) | − (decrease) |
EXPENSE | + (increase) | − (decrease) |
LIABILITY | − (decrease) | + (increase) |
REVENUE | − (decrease) | + (increase) |
EQUITY | − (decrease) | + (increase) |
REVERSAL action inverts the sign.
Non-Obvious Behaviors
| Behavior | Detail |
|---|---|
| Historical process_date | Journals always store the original transaction processDate, not Date.now(). Preserves correct timestamps in bulk reprocessing. |
created_at = transaction time | created_at is set via resolveCreatedAt(record.process_date): equal to process_date when valid, nowMillis() otherwise. Use it for "when did this transaction happen?" queries. The old computeNow 1-hour threshold has been removed. |
generated_at = system ingest time | New field set to nowMillis() at the moment the journal/entry is generated. Use it for "when did our system create this record?" queries. In bulk reprocessing this diverges from created_at. |
modified_at is ours | Always nowMillis() at write time — the accountUpdater updates modified_at again when persisting ledger entries. |
| Rolling reserve only on PAYMENT | addRollingReserveToJournals() is only called for PAYMENT category. REFUND, DISPUTE, VOID, WITHDRAWAL, TOPUP never hold rolling reserves. |
| Ledger entries optional | If LEDGER_ENTRIES module is disabled for an entity, journals persist but no entries are generated and no AccountUpdateQueue messages are sent. |
| validateBalance warns, not throws | If debit ≠ credit, a warning is logged but processing continues. The journal is persisted. |
| transactWrite for pairs | When two journals (IN + OUT) are generated, they are persisted as a single DynamoDB transactWrite — both succeed or both fail. |
| Single journal = putItem | Adjustment, settlement, routing, and RR release generate one journal — persisted with putItem, not transactWrite. |
| Account UpdateQueue is FIFO | MessageGroupId = account.id ensures ordered processing per account — critical for sequential balance ledger. |
| Ledger TTL 15 days | LedgerEntriesTable entries expire after 15 days. DynamoDB TTL handles cleanup automatically. |
| EntriesGeneratorFactory | Different journal categories generate different DEBIT/CREDIT structures. The factory selects the correct generator to ensure accurate double-entry bookkeeping per type. |