#Overview
Payment Emulator Lab is a modular, runtime-delegating system with three applications:
apps/api: one NestJS codebase with an HTTP process and a separate worker.apps/console: a Nuxt operator application with its own Nitro BFF.apps/checkout: a Nuxt buyer application with a different Nitro BFF and provider-specific visual kits.
The backend is deployed as a modular monolith. It is not DDD and does not use repository abstractions over MikroORM. The architecture aims to keep each provider small: a plugin describes and translates its contract, while the shared runtime performs idempotency, concurrency control, canonical state changes, accounting, tracing and webhook scheduling once.
The binding rules are authoritative in .agents/README.md
and .agents/architectures/modular-arch/.
#System view
Diagram source
flowchart TB
subgraph Browsers
OP[Operator]
BUY[Buyer]
end
AUT[Application under test]
subgraph Frontends
CON[Console: Nuxt + PrimeVue]
CHK[Checkout: Nuxt + provider kits]
CN[Console Nitro BFF]
KN[Checkout Nitro BFF]
CON --> CN
CHK --> KN
end
subgraph Backend
HTTP[NestJS HTTP process]
GW[GatewayService]
PL[Provider plugins]
PAY[PaymentsService]
LED[Ledger services]
OUT[webhook_events outbox]
WORK[Worker process]
end
OP --> CON
BUY --> CHK
AUT -->|provider-shaped request| HTTP
CN -->|user session| HTTP
KN -->|public link + optional customer session| HTTP
HTTP --> GW
GW --> PL
GW --> PAY
PAY --> LED
PAY --> OUT
HTTP --> PG[(PostgreSQL)]
WORK --> PG
WORK --> TR[(Transport: PostgreSQL, MongoDB or Redis)]
TR --> WH[Allow-listed webhook endpoint]
PostgreSQL is the durable source of truth. A transport carries webhook jobs —
PostgreSQL itself, MongoDB or Redis, chosen by WEBHOOK_QUEUE_DRIVER — but the
outbox row in PostgreSQL is the delivery commitment; the worker can recreate a
lost job from it. See ADR 011.
#Application boundaries
The three applications share no source code. A shared fact such as provider display name, checkout methods or state vocabulary is served by the API and read at runtime. This protects two important boundaries:
- Backend or operator credentials never enter a browser bundle.
- The buyer checkout does not inherit the console's dependencies, session or security posture.
Both browsers call their own Nitro server routes. apps/console/server/utils/api.ts
forwards the signed-in operator's API session; apps/checkout/server/utils/api.ts
forwards a customer session only when one exists. The checkout remains usable
anonymously for external methods such as card, ticket or bank transfer.
#Backend boundaries
#Controllers declare, services do
Controllers define routes, DTOs, roles and the service call. Transactions,
multi-step writes and business decisions belong in services. For example,
apps/api/src/modules/checkout/checkout.controller.ts only describes the buyer
surface; checkout.service.ts selects the payment method, resolves the optional
customer and delegates the state change to PaymentsService.
#Provider plugins translate; they never write
apps/api/src/core/providers/provider-plugin.ts defines the provider seam:
routes · resource · idempotency · credentials · statuses · errors
webhooks · ledger · checkout · validate · toCanonical · toWire
A plugin may validate provider input, translate it into a canonical command and
render canonical state back into a provider response. It must not use
EntityManager, acquire locks, post ledger entries or enqueue jobs. The registry
at apps/api/src/core/providers/plugin-registry.ts is the only central list of
provider implementations.
#Canonical state stays provider-neutral
payment_intents stores the economic fact: amount in minor units, currency,
capture method, canonical state and funding source. provider_resources stores
the provider ID, original request and provider-shaped response. Fields such as
Mercado Pago's status_detail or Stripe's client_secret never become columns on
the canonical intent.
#One write path
apps/api/src/modules/payments/payments.service.ts is the only payment write
path. Provider requests, checkout completion and timed transitions all converge
there. It owns the ordering between locks, state transitions, ledger effects and
transactional webhook scheduling.
#Persistence is a runtime, not a repository layer
Services use MikroORM's EntityManager directly. Transactions pass the same
transactional manager through payment, ledger and outbox writes. Raw SQL is used
for read aggregation and PostgreSQL locking, never to write ledger postings.
#Provider request path
Authenticated payment requests follow this order in
apps/api/src/modules/gateway/gateway.service.ts:
- Accept the provider's path, optionally prefixed with
/mercadopagoor/stripeor/paypal. - Resolve the application and provider from the bearer credential; if a prefix exists, it must agree.
- Map method and path to a canonical operation through
plugin.routes. - Resolve the application's scenario or
X-Emulator-Scenariooverride. - Run
plugin.validatebefore a payment write. - Apply latency, timeout or HTTP failure declared by the scenario.
- Translate with
plugin.toCanonicaland callPaymentsService.execute. - Render the provider response with
plugin.toWire. - Commit any owed webhook row in the same database transaction as the payment.
See Critical request flows for sequence diagrams.
#Worker responsibilities
apps/api/src/worker.ts is a second process over the same backend code and
database. It performs three independent loops:
| Loop | Default interval | Work |
|---|---|---|
| Outbox dispatch | 1 second | Claims due webhook_events and hands them to the transport. Not started at all under the postgres driver, which reads those rows itself |
| Timed transitions | 1 second | Moves eligible requires_action payments to captured or failed |
| Settlement | 60 seconds | Releases captured reserve into merchant payable after T+N |
The delivery handler signs with the provider plugin, sends only to an allow-listed host and records each attempt. Advisory locks and state re-reads make timed transitions and settlement safe when workers overlap.
#Control-plane authorization
The control plane uses RolesGuard plus explicit @Roles(...) declarations.
There are two credential types:
ADMIN_TOKENis the root bootstrap credential. It acts with admin authority but is not a person.POST /auth/loginissues a revocable user-session token for anadmin,merchantorcustomer.
ApplicationScope and ApplicationScopeGuard centralize row-level visibility.
An application outside a caller's scope is answered as 404 so its existence is
not disclosed. Ownership is taken from the session; a merchant cannot assign an
application to another owner through the request body.
#Frontend rules
#Console
Pages declare data needs through the console's core gateway and collection
composables. Nitro is the only API client. PrimeVue is used only here. Lists use
the shared { data, meta } contract with per-endpoint allow-lists for sorting,
filtering and search.
#Checkout
The shared shell is provider-neutral. A provider's appearance and flow live in
apps/checkout/providers/<id>/; routed pages select the kit through
apps/checkout/app/core/kits.ts. Do not add a provider if to a shared visual
component and do not import console or backend code.
#Dependency rules
Diagram source
flowchart LR
Controller --> Service
Service --> EntityManager[MikroORM EntityManager]
Gateway --> Plugin
Gateway --> Payments
Payments --> Idempotency
Payments --> Ledger
Payments --> Webhooks
Plugin --> CoreTypes[core types and money helpers]
Page --> FrontendGateway[app core gateway]
FrontendGateway --> Nitro
Nitro --> API
Forbidden dependency directions:
- Plugins → database, payment services, ledger or queue.
- Core/modules → a conditional on a provider ID.
- Browser code → NestJS API directly.
- Console ↔ checkout source imports, or either frontend → backend source.
- Ledger balance → mutable stored column.
#Architectural decisions and known deferrals
The main decisions are recorded in docs/en/architecture/decisions/: modular monolith, MikroORM,
canonical JSON, PostgreSQL/JSONB, the queue as non-durable runtime, provider
plugins, deterministic scenarios, explicit contract ingestion, double-entry
accounting, no runtime provider proxy, and a pluggable webhook transport —
PostgreSQL, MongoDB or Redis behind one port.
One layout deferral remains: entities are centralized under
apps/api/src/infrastructure/database/entities/ and modules are mostly flat,
instead of the per-module folder shape in the NestJS binding. This is documented
and should not be “fixed” incidentally in a feature change.
PayPal OAuth exchange and the public local certificate are auxiliary gateway routes, outside the payment transaction flow above.