Skip to content
Payment Emulator LabDocumentationOpen console

Architecture

Critical request flows

This document follows three paths that cross the most important boundaries. Filenames are the current implementation, not Graphify's historical src/... paths.

5 min read

Índice / Index · Español

This document follows three paths that cross the most important boundaries. File names are the current implementation, not Graphify's historical src/... paths.

#1. Provider payment request

Example: an integration creates a Stripe PaymentIntent or Mercado Pago Order.

Diagram source
mermaid
sequenceDiagram
    participant AUT as Application under test
    participant GC as GatewayController
    participant GS as GatewayService
    participant P as ProviderPlugin
    participant PS as PaymentsService
    participant DB as PostgreSQL

    AUT->>GC: Provider path + bearer token
    GC->>GS: handle(path, request)
    GS->>DB: Resolve application credential
    GS->>P: Match route
    GS->>DB: Resolve scenario and start trace
    GS->>P: Validate request
    GS->>PS: Execute canonical command
    PS->>DB: Lock/idempotency/state/ledger/outbox transaction
    PS->>P: toWire(canonical view)
    P-->>AUT: Provider-shaped response

Step by step:

  1. apps/api/src/modules/gateway/gateway.controller.ts catches the provider path. A provider prefix is optional; the credential decides the provider and tenant.
  2. gateway.service.ts resolves the application through ApplicationsService.resolveByAccessToken and rejects a disagreeing prefix.
  3. plugin.routes maps the real provider path to payment.create, payment.update, payment.confirm, payment.capture, payment.cancel, payment.get or payment.refund.
  4. ScenariosService.resolve chooses the application's scenario or the X-Emulator-Scenario override. Trace events record this decision after redaction.
  5. plugin.validate checks the provider contract. Nothing payment-related has been written yet.
  6. GatewayService applies scenario latency, timeout or HTTP error.
  7. plugin.toCanonical creates an IntentCommand; PaymentsService.execute checks the provider's idempotency rule and enters the single write path.
  8. IdempotencyService.run takes a transaction-scoped PostgreSQL advisory lock, replays the stored outcome when appropriate, or runs the write transaction.
  9. Payment state, transitions, ledger postings, provider resource and owed webhook row are committed together.
  10. plugin.toWire renders the provider response. Core refusals are translated by plugin.errors.shape() at the gateway boundary.

To modify this flow, first decide whether the rule is provider-specific (apps/api/src/providers/<id>/) or universal (core/, gateway, payments, ledger or webhooks). A provider-specific if in a core service is an architecture defect.

#2. Buyer completes a checkout

The checkout link is a capability. Authentication is optional: it adds wallet payment, while card/transfer/ticket remain available anonymously.

Diagram source
mermaid
sequenceDiagram
    participant B as Buyer browser
    participant N as Checkout Nitro
    participant C as CheckoutService
    participant P as PaymentsService
    participant DB as PostgreSQL

    B->>N: GET provider checkout URL
    N->>C: GET /checkout/:provider/:resourceId
    C->>DB: Load resource, intent, optional customer session
    C-->>B: Amount, methods, own balance only
    B->>N: Confirm selected method
    N->>C: POST /checkout/.../confirm
    C->>C: Require customer only for wallet method
    C->>P: completeFromCheckout()
    P->>DB: Lock, transition, accounting, outbox
    C-->>B: Updated checkout view

Relevant files:

  • Browser pages: apps/checkout/app/pages/mercadopago/checkout/[resourceId].vue and apps/checkout/app/pages/stripe/c/pay/[resourceId].vue.
  • Provider kits: apps/checkout/providers/<id>/Checkout.vue and theme.css.
  • Nitro proxy: apps/checkout/server/api/checkout/[provider]/[resourceId]/.
  • API surface: apps/api/src/modules/checkout/checkout.controller.ts and checkout.service.ts.
  • Shared write: apps/api/src/modules/payments/payments.service.ts.

For a wallet method, CheckoutService resolves a valid customer session, takes the walletKey from that identity and checks the person's own balance. A customerId from the request body is never trusted. For other methods, fundingSource is external and the ledger uses system cash rather than a customer wallet.

PaymentsService.completeFromCheckout takes an advisory lock and refreshes the intent inside it. That refresh is essential: without it, concurrent clicks can read stale identity-map state and charge more than once.

#3. Webhook delivery

Diagram source
mermaid
sequenceDiagram
    participant PS as PaymentsService
    participant DB as PostgreSQL
    participant OD as OutboxDispatcher
    participant Q as Transport (Postgres, Mongo or Redis)
    participant W as Worker handler
    participant I as Integration webhook

    PS->>DB: Commit payment + webhook_event
    OD->>DB: Claim due rows (SKIP LOCKED)
    OD->>Q: Hand over the delivery
    Q->>W: Dispatch with retries
    W->>DB: Read event and application
    W->>W: Plugin signs payload
    W->>I: POST allow-listed callback
    W->>DB: Record attempt and final status
  1. WebhooksService.schedule creates WebhookEventEntity with the payment's transaction manager. If either write fails, neither commits.
  2. OutboxDispatcher.sweep claims due or stale rows with FOR UPDATE SKIP LOCKED, stamps queued_at, then hands them to the transport. Under the postgres driver it does not run at all: that driver reads the same rows, and two claimers on one table is a race.
  3. A failed enqueue loses no notification; the stale sweep can claim the row again.
  4. The handler in apps/api/src/worker.ts checks RUNTIME_OUTBOUND_ENABLED, URL protocol, and that the host resolves to a public address (WEBHOOK_ALLOWED_HOSTS being the exceptions).
  5. The provider plugin supplies topic, payload and signature. The worker supplies transport and retry policy.
  6. WebhookAttemptEntity records HTTP status, duration or error. Scenarios may delay, duplicate or deliberately mis-sign an event.

The delivery model is at-least-once. Integrations must be idempotent; duplicate delivery is both possible operationally and intentionally testable.

#Control-plane requests

Control-plane controllers declare RolesGuard, and application-scoped ones also declare ApplicationScopeGuard. Console browser requests go through apps/console/server/api/; apps/console/server/utils/api.ts forwards the signed-in person's token from a sealed cookie. Sorting, filtering and search are validated by the endpoint's allow-list and always return { data, meta }.

PayPal approval ends in approved, without a debit or hold. Merchant authorization or capture is a separate request; see PayPal fidelity.

Payment Emulator Lab · RonuSoftwareMIT