All projects

MACHBANK · 2025

Automated Provider Compensation Flow

Senior Software Engineer

Zero-touch daily settlement of all Bip QR recharge payments to the provider, with full audit trail, dual-amount reconciliation, and Slack observability at every step.

TypeScriptNode.jsAWS EventBridgeAWS SNSMongoDBRatatoskr

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:

  1. Checks if today is a Chilean business day using the date-holidays library — weekends and public holidays both excluded
  2. 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
  3. Queries MongoDB for all payments with status = COMPLETED and completedAt within that window, sums their amounts
  4. Creates a Compensation document with state CREATED, the calculated amount, and the date range
  5. Emits a prefunded-payments-deposit-to-internal-account event immediately, triggering the next handler
  6. 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:

  1. Validates the compensation is in state CREATED
  2. 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
  3. Advances state to INTERNAL_TRANSFERED
  4. 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:

  1. Updates providerAmount on the compensation record
  2. Calculates the percentage difference between internal and provider amounts and surfaces it in Slack
  3. 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:

  1. Finds the latest compensation in AMOUNT_RECEIVED state
  2. Safety check: if providerAmount is zero or missing, marks the compensation OMITTED and notifies Slack — no money moves
  3. Calls BusinessPayroll.retrieveFunds() to debit the Transfer Account by the provider’s amount
  4. Advances state to INTERNAL_RETRIEVED
  5. 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:

  1. Validates compensation is in INTERNAL_RETRIEVED
  2. Calls BusinessPayroll.transferFunds() to credit Orcen’s Globe account from the Transfer Account
  3. Advances state to COMPLETED
  4. 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.