The Problem
MACHBANK needed a QR-based payment system that let users pay at physical merchants using only their phone. The system had to handle two distinct payment surfaces — consumer (MACH ecosystem users) and business (SDK channel partners like BCI) — across multiple acquirers with different protocols, different card enrollment requirements, and different authorization flows. And it had to be extensible: new acquirers, new channels, and new payment methods would be added over time.
A single monolithic service would have made every extension a surgery. The architecture needed to absorb that change cleanly.
Three-Service Architecture
The platform is split into three services with distinct responsibilities.
mach-eco-p2m-service — the consumer BFF. Handles the mobile payment experience end-to-end: QR scanning, card enrollment, payment creation, authorization, execution, and receipts for MACH ecosystem users.
mach-business-sdk-service — the business BFF. Serves SDK channel partners (BCI, MACH wallet) behind a single payment API.
mach-p2m-service — the core. Owns transaction persistence, state machine enforcement, and all acquirer integrations.
Both BFFs expose the same payment contract to clients — create, authorize, execute, receipt — and return the same shape of data. What’s different is where that data comes from.
| eco-p2m (consumer) | business-sdk (business) | |
|---|---|---|
| Transaction source | mach-p2m-service (Transbank / MultiPay acquirers) | business-payments service |
| Payment methods | MongoDB (enrolled cards) + Spreedly tokenization | Deposit accounts service (MACH) or BCI Banking API (BCI) |
| Authorization | SDK auth service (2FA) + ARIC fraud engine | Challenge token (BCI) or authorization service (MACH) |
This is why two BFFs exist rather than one. It’s not an organizational split — it’s that the data sources are fundamentally different on each side. Consumer payments use cards stored and tokenized via Spreedly, managed in the p2m database. Business payments draw from bank accounts fetched live from deposit services or BCI’s banking API. Merging both into one BFF would mean every request carrying conditionals for which data source to use, at every layer. Separating them means each BFF can be straightforward about where its data lives.
Acquirer Strategy Pattern
mach-p2m-service supports multiple payment acquirers behind a common interface:
interface IAcquirer {
read(qrInput): Promise<IAcquirerTransactionInput>
isValid(input): Promise<boolean>
requestPayment(transaction, paymentCard, paymentType, fees): Promise<IRequestPaymentOutput>
processPaymentNotification(args, transaction): IPaymentTransactionWithId
retrieveDetailsAndUpdateTransaction(transaction): Promise<IPaymentTransactionWithId>
getFailedAuthorizationStatus(): string
notifyRejection(transaction): Promise<void>
}
Three implementations exist: TransbankV2.0.17 (current — EMV QR, certificate signing, amount/tip decryption with app keys), TransbankV1.9.9 (legacy, backward compatibility), and MultiPay (alternative acquirer). A factory resolves the correct implementation at runtime. Adding a fourth acquirer means implementing the interface — nothing else changes.
Each acquirer handles Transbank’s webhook callbacks via processPaymentNotification, which parses the acquirer-specific payload and maps it to the internal transaction model. The core service never knows which acquirer format it’s receiving — that’s the acquirer’s problem.
Channel Orchestrator Pattern
The business BFF serves two channel types with fundamentally different account models and authorization mechanisms. Rather than branching on channel type everywhere, this is resolved with an abstract ChannelOrchestrator:
interface ChannelOrchestrator {
getAccounts(args): Promise<IAccount[]>
getAccountNumber(args): Promise<string>
executePayment(transaction, paymentArgs, account): Promise<void>
validateTransactionOwnership(identifiers, payerId): void
}
BCIOrchestrator fetches accounts from BCI’s banking API, maps BCI account types to display titles, and authorizes via challenge-token. MACHOrchestrator fetches deposit accounts from the deposit-accounts service, validates ACTIVE state, and authorizes via the SDK authorization service.
A factory selects the orchestrator based on the channel code. Payment creation, execution, and receipt endpoints call orchestrator methods — the channel is invisible at the business logic level.
XState Transaction State Machine
Every payment transaction in mach-p2m-service is governed by a formal XState state machine:
REGISTERED
└─→ DELIVERY_REQUESTED
├─→ DELIVERY_REQUEST_FAILED
├─→ DELIVERY_REQUEST_RECEIVED
│ ├─→ AUTHORIZED
│ ├─→ REJECTED
│ ├─→ REVERSED
│ ├─→ NULLIFIED
│ └─→ PARTIALLY_AUTHORIZED
│ ├─→ AUTHORIZED
│ ├─→ REVERSED
│ └─→ NULLIFIED
└─→ AUTHORIZED
├─→ AUTHORIZED ← idempotent (safe retry)
├─→ PARTIALLY_AUTHORIZED
├─→ REVERSED
└─→ NULLIFIED
The verifyTransition() function checks every state change against the machine before it’s applied. Invalid transitions throw. One deliberate exception: AUTHORIZED → AUTHORIZED is allowed idempotently — if Transbank sends a duplicate webhook after a network retry, the transaction doesn’t break.
Both the transaction model and the card model maintain an immutable status history with timestamps, creating an append-only audit trail for every state change.
API Versioning
Every endpoint supports multiple versions simultaneously via the accept-version HTTP header. Version routing maps semver ranges to handlers:
const availableActions = {
'>=2.2.0': v2_2_0,
'>=2.1.0': v2_1_0,
'>=2.0.0': v2_0_0,
'>=1.1.0': v1_1_0,
'>=1.0.0': v1_0_0,
}
getActionByVersion() selects the richest handler the client version supports. Old clients continue getting their version; new features ship to new clients without a migration forcing function. The payment create endpoint alone has five active versions running simultaneously.
Cross-Cutting Infrastructure
Async context threading — AsyncLocalStorage carries trace context (request ID, document number, channel code) through all async boundaries. Every log line from every downstream call is automatically correlated to the originating request without passing context explicitly.
Unified request wrapper — expressAction<T, R>() wraps every route handler, applying Joi validation, standardized error mapping, structured logging, and event publishing in one place. Business logic functions are pure: input in, output out.
Event publishing — every terminal state change publishes a fire-and-forget event to mach-maas-events-service. Downstream systems (analytics, notifications, settlement) consume these asynchronously. Failures are logged but never block the payment response.
Certificate management — Transbank V2.0.17 requires mutual TLS with signed requests. On startup, the service fetches the certificate from S3 and registers it in the crypto layer. Request signing and response decryption happen transparently inside the acquirer implementation.
Fraud prevention — ARIC fraud engine integration runs at authorization time for consumer payments. Device fraud checks run at execution time for business payments. Both are injected at the orchestration layer and are invisible to the acquirer implementations.