Skip to content
Payment Emulator LabDocumentationOpen console

Architecture

Payment Emulator Lab architecture

Payment Emulator Lab is a modular, runtime-delegating system with threeapplications:

6 min read

Índice / Index · Español

#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
mermaid
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:

text
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:

  1. Accept the provider's path, optionally prefixed with /mercadopago or /stripe or /paypal.
  2. Resolve the application and provider from the bearer credential; if a prefix exists, it must agree.
  3. Map method and path to a canonical operation through plugin.routes.
  4. Resolve the application's scenario or X-Emulator-Scenario override.
  5. Run plugin.validate before a payment write.
  6. Apply latency, timeout or HTTP failure declared by the scenario.
  7. Translate with plugin.toCanonical and call PaymentsService.execute.
  8. Render the provider response with plugin.toWire.
  9. 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_TOKEN is the root bootstrap credential. It acts with admin authority but is not a person.
  • POST /auth/login issues a revocable user-session token for an admin, merchant or customer.

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
mermaid
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.

Payment Emulator Lab · RonuSoftwareMIT