Skip to content
Payment Emulator LabDocumentationOpen console
On this page

Runtime

Deterministic payment scenarios

JSON is canonical. Scenario matching is deterministic and allows provider, operation, amount bounds and exact metadata matching. Arbitrary JavaScript is forbidden.

3 min read

Índice / Index · Español

JSON is canonical. Scenario matching is deterministic and allows provider, operation, amount bounds and exact metadata matching. Arbitrary JavaScript is forbidden.

Built-in scenarios live only as canonical files under scenarios/<provider>/*.json. npm run scenario:compile validates the safe DSL and generates apps/api/src/modules/scenarios/default-scenarios.ts as a runtime representation. npm run build, npm run typecheck and npm test run the compiler first, preventing silent drift between JSON and runtime behavior.

Custom scenarios can be persisted through POST /scenarios with { "key": "team/high-value-rejection", "definition": { ... } }. The response returns an immutable-style reference such as team/high-value-rejection@1. Custom sessions must use the explicit @version reference so later versions cannot silently change an existing test.

The DSL intentionally supports a small deterministic rule set. Unknown action keys are rejected; no JavaScript/eval/function execution is available. Redis compilation/cache is a later performance optimization, not the source of truth.

#then.after — a payment that moves on its own

json
{
  "provider": "mercadopago",
  "version": 1,
  "when": { "operation": "payment.create" },
  "then": {
    "outcome": "requires_action",
    "after": { "seconds": 5, "outcome": "captured" },
    "webhook": { "enabled": true }
  }
}

A ticket paid at a shop counter, a transfer that clears overnight, a wallet that redirects and comes back. In every one of them the payment changes while the integration does nothing, and the integration is told by webhook — which is the case it is most likely to get wrong, and the hardest to reproduce against a real provider, because there is no request you can send to make somebody walk into a shop.

Rule Why
Only on a payment that ends in requires_action That is the one state where it waits for somebody who is not the integration. Everywhere else the integration is the one who acts, and a timer moving it would be the emulator acting on its behalf
outcome is captured or failed A payment left waiting can complete or expire. Refunding or disputing on a timer would be the emulator performing an operation that belongs to the integration
seconds is 1–3600 A scenario is something a test waits for; one that fires tomorrow is a row nobody will ever see move
The money is always external Nobody signed in, and a ticket paid at a shop is cash arriving from the world. Debiting a balance would be inventing a payer

The worker sweeps it on a clock. The control plane can run the sweep on demand — POST /applications/{id}/payments/run-timed-transitions, with an optional asOf — which is what lets a test observe a five-minute ticket without waiting five minutes. The same reason run-settlement exists beside it.

Shipped with three: mercadopago/ticket-paid-later, mercadopago/ticket-expired and stripe/bank-transfer-clears.

#Current DSL gap

then.httpStatus and then.fault.once still exist in the TypeScript scenario shape, but GatewayService does not consume them. Do not use either in a new scenario until runtime behaviour and tests are added. Use then.fault: { "type": "http_error", "status": 429 } for an HTTP failure; it is the implemented path.

Payment Emulator Lab · RonuSoftwareMIT