Appearance
Transaction Processing Flow
Real-time transaction processing: EventBridge events received via SQS, routed through fees and accounting.
Overview
| Attribute | Value |
|---|---|
| Trigger | SQS (TransactionEventsQueue FIFO) |
| Source | EventBridge bus usrv-data-sync-sls-{stage}-transactions-bus |
| Handler | src/handler/transactionOrchestratorHandler.ts |
| Service | src/service/OrchestratorService.ts |
| Filters | PAYMENTS, APMS, WITHDRAWALS, DISPUTES |
The orchestrator normalizes incoming events, validates idempotency, checks module flags, calculates fees, and generates accounting journals. It handles both transaction events (PAYMENT, WITHDRAWAL, etc.) and settlement/reserve events (SETTLEMENT_APPROVE, ROUTING, etc.) that arrive on the same queue.
Flow Diagram
Detail-Type Routing Table
| Detail-Type | Route | Notes |
|---|---|---|
PAYMENTS.PAYMENT | processByTransaction | Card payment |
APMS.PAYMENT | processByTransaction | Alternative payment method |
WITHDRAWALS.WITHDRAWAL | processByTransaction | Payout to merchant |
WITHDRAWALS.TOPUP | processByTransaction | Merchant top-up |
PAYMENTS.REFUND | processByTransaction | Refund |
PAYMENTS.DISPUTE | processByTransaction | Dispute / chargeback |
PAYMENTS.VOID | processByTransaction | Void |
SETTLEMENTS.SETTLEMENT_APPROVE | processBySettlement | Settlement approval |
SETTLEMENTS.SETTLEMENT_CONFIRM | processBySettlement | Settlement confirmation |
SETTLEMENTS.ROUTING | processBySettlement | Routing fee |
SETTLEMENTS.ROLLING_RESERVE_RELEASE | processBySettlement | Reserve release |
PAYOUT (legacy) | processByTransaction | Normalized → WITHDRAWAL |
WAA_DEPOSIT (legacy) | processByTransaction | Normalized → TOPUP |
Transaction Data Extraction
The raw SQS record embeds an EventBridge event with a DynamoDB NewImage payload. getTransactionData() extracts and normalizes into two typed structures:
IPayInData (PAYMENT, APMS_PAYMENT, REFUND, DISPUTE, VOID)
| Field | Source path | Notes |
|---|---|---|
businessId | detail.keys.business_id | |
transactionId | detail.keys.id | |
amount | detail.data.NewImage.amount | |
acq | detail.data.NewImage.acquirer | lowercased |
provider | detail.data.NewImage.provider | lowercased |
currencyCode | detail.data.NewImage.currency_code | may be missing → enriched |
currencyId | detail.data.NewImage.currency_id | used if currencyCode missing |
paymentMethodId | detail.data.NewImage.payment_method_id | |
issuingCountryId | detail.data.NewImage.issuing_country_id | optional |
cardBrand | detail.data.NewImage.card_brand | optional |
paymentId | detail.data.NewImage.payment_id | optional, metadata |
checkoutId | detail.data.NewImage.checkout_id | optional, metadata |
trxCreatedAt | operation_date | created | created_at | epoch ms |
category | determineJournalCategory(type, status) |
IWithdrawalData (WITHDRAWAL, TOPUP)
| Field | Source path | Notes |
|---|---|---|
businessId | detail.keys.business_id | |
transactionId | TOPUP: ${businessId}#${acquirer_reference}; else detail.keys.id | |
amount | detail.data.NewImage.amount | |
acq | detail.data.NewImage.acquirer | lowercased |
method | detail.data.NewImage.method | transfer method |
trxCreatedAt | operation_date | created | created_at | epoch ms |
category | determineJournalCategory(type, status) |
Validation
validateIncomeData
| Event type | Required fields |
|---|---|
| PayIn | paymentMethodId, currencyId, acq, businessId |
| Withdrawal | acq, businessId |
Throws AppError(E004) if validation fails → result: MISSING_INFO.
validateTrxAlreadyProcessed
DynamoDB query:
- Table:
JournalsTable - Index:
process_id-index - Key:
process_id = transactionId - Filter:
category = category
Returns true if any journal exists (idempotency guard).
Data Enrichment
enrichData() fills missing currencyCode before fee calculation:
| Condition | Action |
|---|---|
currencyCode present | Skip (no MongoDB call) |
currencyCode missing, currencyId present | findOne in currencies collection by id |
| Both missing | aggregate on business collection with $lookup to currencies |
Result Codes
| Code | Meaning |
|---|---|
PROCESSED | Transaction fully processed (fees + accounting) |
ALREADY_PROCESSED | Duplicate: journal already exists in DynamoDB |
MISSING_INFO | Required data missing or fee rule not found |
MODULE_DISABLED | FEES_CALCULATION module disabled for this entity |
NOT_SUPPORTED_DETAIL_TYPE | Unknown event type |
Middleware Stack
warmupMiddleware()
└─ inputOutputLoggerMiddleware()
└─ BUILDER_SQS_MIDDLEWARE() ← parse SQS records
└─ makeHandlerIdempotent() ← DynamoDB IdempotencyTable, TTL 120s
└─ MONGODB_CONNECTION_MIDDLEWARE()
└─ OrchestratorService.transactionOrchestrator()Idempotency key: conditional_idempotency_key(@) — custom JMESPath function that extracts the transaction ID from the SQS record. If parsing fails, returns null (idempotency skipped).
Account Updater Handler
src/handler/accountUpdaterHandler.ts — consumes AccountUpdateQueue (FIFO) and calls AccountService.accountUpdater().
| Attribute | Value |
|---|---|
| Queue | AccountUpdateQueue FIFO, maxReceiveCount=160 |
| Handler | src/handler/accountUpdaterHandler.ts |
| Service | src/service/AccountService.ts — accountUpdater() |
| Middleware | warmupMiddleware, inputOutputLoggerMiddleware, BUILDER_SQS_MIDDLEWARE |
Messages arrive after accounting journals are persisted (see accounting.md). Each message contains ledger entries for a single account. accountUpdater() applies entries sequentially, maintaining running balance and annotating each entry with balance_before/balance_after.
Non-Obvious Behaviors
| Behavior | Detail |
|---|---|
| 2-day cutoff | now - trxCreatedAt > 172_800_000ms returns ALREADY_PROCESSED. Only applies in real-time flow — bulk reprocess bypasses this. |
| TOPUP transaction ID | Constructed as ${businessId}#${acquirer_reference}, not the raw record ID. |
| PAYOUT / WAA_DEPOSIT | Legacy detail-types silently normalized before routing. No error, no log warning. |
| FIFO per businessId | MessageGroupId = businessId ensures ordering within a business; different businesses process in parallel. |
| Idempotency TTL 120s | IdempotencyTable caches responses for 2 minutes. Duplicate SQS deliveries within that window get the cached result without reprocessing. |
| Module flags | FEES_CALCULATION can be disabled per entity via FinancesConfigService. If disabled, returns MODULE_DISABLED without calling fees or accounting. |
| Fixed APM acquirers | bitso, oxxopay, mercadopago always map to DetailTypeEnum.APMS_PAYMENT regardless of transaction_type. |
| processBySettlement skips fees | Settlement/routing/reserve events go directly to accountingPreparer() — no fee calculation step. |
| E003/E004 → MISSING_INFO | Known AppError codes (missing account, missing data) return MISSING_INFO and record a CloudWatch metric. Other errors propagate and fail the SQS message. |