Skip to content

Accounting Flow

Double-entry bookkeeping: journals + ledger entries generated per transaction category.


Overview

AttributeValue
Called byOrchestratorService (real-time) and bulk processor services
Servicesrc/service/accounting/AccountingService.ts
Interfacesrc/repository/IAccountingService.ts
Journals storageDynamoDB JournalsTable
Entries storageDynamoDB 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):

OptionDefaultDescription
skipModuleCheckfalseSkip the isLedgerEnabled check. Used by bulk/settlement flows.
skipAccountUpdatefalseSkip 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):

AccountPK patternSK
acqReceivableAccountACQ#{acquirer}#CUR#{currency}ACCT#ACQUIRER_RECEIVABLE
acqReserveReceivableAccountACQ#{acquirer}#CUR#{currency}ACCT#RESERVE_RECEIVABLE
businessPayableAccountENT#{entity_id}#CUR#{currency}ACCT#BUSINESS_PAYABLE
businessReservePayableAccountENT#{entity_id}#CUR#{currency}ACCT#RESERVE_PAYABLE
platformClearingAccountENT#T1#CUR#{currency}ACCT#PLATFORM_CLEARING
processingFeesRevenueAccountENT#T1#CUR#{currency}ACCT#PROCESSING_FEES_REVENUE
vatPayableAccountENT#T1#CUR#{currency}ACCT#VAT_PAYABLE
feesExpenseFeeAccountENT#T1#CUR#{currency}ACCT#ACQUIRER_FEES_EXPENSE
vatReceivableAccountENT#T1#CUR#{currency}ACCT#VAT_RECEIVABLE

VOID:

AccountPK patternSK
acqReceivableAccountACQ#{acquirer}#CUR#{currency}ACCT#ACQUIRER_RECEIVABLE
businessPayableAccountENT#{entity_id}#CUR#{currency}ACCT#BUSINESS_PAYABLE
platformClearingAccountENT#T1#CUR#{currency}ACCT#PLATFORM_CLEARING

WITHDRAWAL / TOPUP:

AccountPK patternSK
acqWithdrawalFundsAccountACQ#{acquirer}#CUR#{currency}ACCT#WITHDRAWAL_FUNDS
businessWithdrawalPayableAccountENT#{entity_id}#CUR#{currency}ACCT#BUSINESS_WITHDRAWAL_PAYABLE
businessPayableAccountENT#{entity_id}#CUR#{currency}ACCT#BUSINESS_PAYABLE
platformClearingAccountENT#T1#CUR#{currency}ACCT#PLATFORM_CLEARING
withdrawalFeesRevenueAccountENT#T1#CUR#{currency}ACCT#WITHDRAWAL_FEES_REVENUE
vatPayableAccountENT#T1#CUR#{currency}ACCT#VAT_PAYABLE
withdrawalFeesExpenseFeeAccountENT#T1#CUR#{currency}ACCT#WITHDRAWAL_ACQUIRER_FEES_EXPENSE
vatReceivableAccountENT#T1#CUR#{currency}ACCT#VAT_RECEIVABLE
acqFeesPayableAccountACQ#{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_typeAccounts
in_approveacqReceivableAccount, bankAccount
out_approvebusinessPayableAccount, businessSettlementPendingAccount, processingFeesRevenueAccount, vatPayableAccount
out_confirmbankAccount, 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 = INacqReserveReceivableAccount, bankAccount
  • type = OUTbusinessReservePayableAccount, businessPayableAccount, processingFeesRevenueAccount, vatPayableAccount

Single journal. Links back to original via related_journal_id = original_journal.id.


Journal Structure

All journals share these core fields:

FieldValueNotes
idUUID v7Unique per journal
typeIN or OUT
categoryJournalCategoryEnumPAYMENT, REFUND, SETTLEMENT_APPROVE, etc.
process_idTransaction/settlement IDUsed for dedup via process_id-index
entity_idBusiness ID
currency_codeISO currency
gross_amountOriginal transaction amount
fee_amountTotal fees
iva_amountIVA on fees
net_amountNet after fees
rr_amountRolling reserve heldPAYMENT only
acquirerAcquirer identifier
providerProvider identifier
process_dateOriginal transaction timestampPreserved in bulk — not Date.now()
created_atOriginal transaction timestamp (= process_date when valid, else nowMillis())Use this to query "when did this transaction happen?"
generated_atWhen our system created this recordAlways nowMillis() at the moment of generation
modified_atWhen the record was last modifiedInitially equal to generated_at; updated later by accountUpdater for ledger entries
related_journal_idID of paired journalIN links to OUT and vice versa
settlement_idSettlement IDSet later by settlementJournalUpdater
expected_reserve_release_dateRelease date epoch msPAYMENT only
descriptionHuman-readable descriptionTemplate per category/type
metadataFee rules + extra context

created_at and generated_at semantics

Each journal and ledger entry carries two distinct timestamp fields:

FieldMeaningValue
created_atWhen the underlying business event happened (the original transaction date)process_date when it is a valid epoch-ms number, otherwise nowMillis()
generated_atWhen our system created this recordAlways nowMillis() at the moment of generation
modified_atWhen the record was last modified by our systemInitially equal to generated_at; updated by accountUpdater to its own nowMillis() when persisting ledger entries
process_dateOriginal transaction timestamp from the source eventPreserved 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_at to query "when did this transaction happen?" and generated_at to 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_at keeps the original transaction date and generated_at shows 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_amount

hold_reserve_period_days comes from the matched fee rule (IN side).


Ledger Entry Persistence

When ledgerEnabled = true:

  1. generateEntriesByCategory() delegates to EntriesGeneratorFactory.createGenerator(category) — returns a category-specific generator that produces DEBIT/CREDIT entry pairs.
  2. validateBalance() sums all debits and credits; logs a warning if they don't match (does not throw).
  3. Entries are grouped by account_id.
  4. 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

FieldDescription
idUUID
account_idAccount UUID
journal_idParent journal ID
typeDEBIT or CREDIT
amountEntry amount
process_dateOriginal transaction date (preserved)
process_idTransaction ID
entity_idBusiness ID
currency_codeISO currency
balance_account_beforeAccount balance before this entry
balance_account_afterAccount balance after this entry
seqSequential number within the account
ttl_expirationEpoch seconds — TTL set to 15 days after creation

Account Balance Rules

AccountService.calculateDeltaByAccountType() determines the sign of each entry:

Account typeDEBITCREDIT
ASSET+ (increase)− (decrease)
EXPENSE+ (increase)− (decrease)
LIABILITY− (decrease)+ (increase)
REVENUE− (decrease)+ (increase)
EQUITY− (decrease)+ (increase)

REVERSAL action inverts the sign.


Non-Obvious Behaviors

BehaviorDetail
Historical process_dateJournals always store the original transaction processDate, not Date.now(). Preserves correct timestamps in bulk reprocessing.
created_at = transaction timecreated_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 timeNew 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 oursAlways nowMillis() at write time — the accountUpdater updates modified_at again when persisting ledger entries.
Rolling reserve only on PAYMENTaddRollingReserveToJournals() is only called for PAYMENT category. REFUND, DISPUTE, VOID, WITHDRAWAL, TOPUP never hold rolling reserves.
Ledger entries optionalIf LEDGER_ENTRIES module is disabled for an entity, journals persist but no entries are generated and no AccountUpdateQueue messages are sent.
validateBalance warns, not throwsIf debit ≠ credit, a warning is logged but processing continues. The journal is persisted.
transactWrite for pairsWhen two journals (IN + OUT) are generated, they are persisted as a single DynamoDB transactWrite — both succeed or both fail.
Single journal = putItemAdjustment, settlement, routing, and RR release generate one journal — persisted with putItem, not transactWrite.
Account UpdateQueue is FIFOMessageGroupId = account.id ensures ordered processing per account — critical for sequential balance ledger.
Ledger TTL 15 daysLedgerEntriesTable entries expire after 15 days. DynamoDB TTL handles cleanup automatically.
EntriesGeneratorFactoryDifferent journal categories generate different DEBIT/CREDIT structures. The factory selects the correct generator to ensure accurate double-entry bookkeeping per type.

Vecnet — Build Spec v0.2 · Obsidian Terminal