The Problem
MACHBANK’s Bip QR product lets users recharge their transit cards directly from the app. Every completed recharge generates a payment that MACHBANK owes to Orcen (Globe) — the underlying provider. Those payments needed to be settled daily, on a precise schedule, through a chain of banking operations across multiple accounts.
There was no existing system to do this. The settlement process had to be designed from scratch, with specific constraints:
- Settlement must happen on business days only — weekends and Chilean public holidays excluded, with correct lookback logic when days are skipped
- Two separate parties calculate the expected amount independently: MACHBANK sums completed payments internally; Orcen sends their own expected figure via email. The final transfer uses the provider’s number, but both amounts are tracked for reconciliation
- Money can’t move in a single step — it flows through an intermediate Transfer Account, giving the finance team a window to review discrepancies before the provider’s deadline
- Every step must be retryable on infrastructure failure, but must not double-execute on business logic failure
- The entire flow must be auditable with Slack notifications at each stage
Architecture: Event-Driven State Machine
The compensation flow is modeled as a state machine with five atomic handlers, each responsible for exactly one state transition. No handler knows about the steps before or after it — it validates its expected input state, does one thing, advances the state, and either dequeues or requeues.
CREATED → INTERNAL_TRANSFERED → AMOUNT_RECEIVED → INTERNAL_RETRIEVED → COMPLETED
Two AWS EventBridge cronjobs trigger the flow at specific times. Everything else is event-driven via SNS topics.
Cron 1 — Compensation Calculation (14:00 CLT, Monday–Friday)
"schedule_expression": "cron(0 17 ? * MON-FRI *)"
This fires at 17:00 UTC (14:00 Chile CLT during summer UTC-3), triggering compensation-triggered. The handler:
- Checks if today is a Chilean business day using the
date-holidayslibrary — weekends and public holidays both excluded - Iterates backwards to find the correct compensation window start: if yesterday was a business day, the window is
[yesterday 14:00 CLT → today 13:59 CLT]; if a weekend or holiday intervened, it walks back until it finds the nearest business day - Queries MongoDB for all payments with
status = COMPLETEDandcompletedAtwithin that window, sums their amounts - Creates a
Compensationdocument with stateCREATED, the calculated amount, and the date range - Emits a
prefunded-payments-deposit-to-internal-accountevent immediately, triggering the next handler - Posts a Slack notification with the compensation ID, accounting period, and calculated amount
Handler 2 — Deposit to Transfer Account (immediate)
deposit-to-internal-account runs immediately after compensation creation. It:
- Validates the compensation is in state
CREATED - Calls
BusinessPayroll.transferFunds()to move the internally calculated amount from the Product Account (Cuenta Contable) into the Transfer Account (Cuenta Corriente BCI) — all within the BCI banking ecosystem - Advances state to
INTERNAL_TRANSFERED - Notifies Slack
This step funds the Transfer Account using MACHBANK’s own calculation, independent of what the provider will claim.
Handler 3 — Provider Amount Update (manual, Banking Ops)
On the following accounting day, Orcen sends an email with the amount they expect to receive. The Banking Ops team processes this and triggers update-compensation-amount with the provider’s figure.
This handler:
- Updates
providerAmounton the compensation record - Calculates the percentage difference between internal and provider amounts and surfaces it in Slack
- Advances state to
AMOUNT_RECEIVED
If the discrepancy is significant, the finance team can intervene before money moves.
Cron 2 — Payment Execution (12:00 CLT, Monday–Friday)
"schedule_expression": "cron(0 15 ? * MON-FRI *)"
This fires at 15:00 UTC (12:00 Chile CLT), triggering retrieve-to-internal-account. Firing at 12:00 gives the finance team two hours to review before Orcen’s 14:00 deadline.
retrieve-to-internal-account:
- Finds the latest compensation in
AMOUNT_RECEIVEDstate - Safety check: if
providerAmountis zero or missing, marks the compensationOMITTEDand notifies Slack — no money moves - Calls
BusinessPayroll.retrieveFunds()to debit the Transfer Account by the provider’s amount - Advances state to
INTERNAL_RETRIEVED - Immediately emits
deposit-to-provider-account, triggering the final handler
Handler 5 — Deposit to Provider (immediate)
deposit-to-provider-account is the last step in the chain:
- Validates compensation is in
INTERNAL_RETRIEVED - Calls
BusinessPayroll.transferFunds()to credit Orcen’s Globe account from the Transfer Account - Advances state to
COMPLETED - Posts final Slack notification: COMPENSACIÓN FINALIZADA
Retry Strategy
Each handler uses the same shouldRequeueError logic: MongoDB connection errors and HTTP failures from the banking service trigger a requeue (the message goes back on the SNS queue for retry). Business logic errors — wrong state, compensation not found — dequeue without retry.
This means infrastructure hiccups are handled automatically. Business logic violations are logged to New Relic and surfaced for manual review.
Why Two Amounts
The dual-amount design answers a specific constraint: MACHBANK must fund the Transfer Account based on its own accounting (the sum of completed payments), but the provider’s email is the authoritative figure for the actual transfer. If they differ:
- The Transfer Account has already been funded with MACHBANK’s figure
- The provider gets paid their figure
- Any gap is reconciled manually by the finance team via adjustments to the Transfer Account
The internal amount is never thrown away — it’s the audit trail and the reconciliation baseline.