Enforcing Charge-Capture-Refund Sequencing in Integration Tests Against a Stateful Payment Simulator
Stateful simulators enforce payment state machines that stateless mocks cannot test.
Integration testing against stateful APIs requires a simulator that remembers prior requests, enforces legal transitions, and exposes the resulting state for assertions. For charge-capture-refund flows, tests must check payment records, refundable balances, and the effects on the ledger as well as HTTP responses. A stateless mock cannot enforce this lifecycle because it has no record of what happened before.
Why the charge-capture-refund lifecycle is a state machine, not a sequence of HTTP calls
A payment operation is legal only when the payment’s current state permits it. Authorization, capture, void, and refund are not independent requests that happen to run in a preferred order. Each changes which operations may follow and how much value those operations may affect. Order is part of the financial contract. Skipping a required transition is a business error.
Void and refund illustrate that distinction. A void releases an uncaptured authorization, while a refund returns value after capture. A provider’s rules can allow other transitions, so the machine must match the provider it simulates. Authorize.net’s documentation says voids are allowed for transactions in Authorized/Pending Capture or Captured/Pending Settlement. A rule that allows voiding only before capture would get that provider’s behavior wrong.
Adyen’s modification testing guidance enumerates full capture, partial capture, multiple partial captures, cancellation, full refund, partial refund, multiple partial refunds, reversal, and pre-authorization adjustment. These are distinct transitions. They also change accounting entries, settlement timing, fees, and the records that customers see. The authoritative test oracle is therefore the payment object and ledger after the operation, including when the response is an HTTP 200 or a success flag.
What breaks when the state machine is violated in production
An application that conflates void and refund selects the wrong financial operation for the payment’s state. A refund against a payment that was never captured is another sequencing violation. Tests can miss either error when the test infrastructure accepts the request without checking the prior state. The application then runs into the rule in production because its tests never enforced it.
Concurrency creates failures that a sequential happy path cannot expose. Two refund requests can each look valid against the same refundable balance, even though together they ask for more than the captured remainder. The refunds that are accepted must stay within that limit. A race occurs when both requests are evaluated without accounting for the other’s effect, allowing an over-refund that only appears under concurrent load.
Retries and asynchronous events introduce different ordering problems. Reusing one idempotency key across authorize, capture, and refund, or generating it client-side where it can be lost on retry, opens those operations to duplication. Each step requires its own namespaced key. Meanwhile, a capture webhook can arrive before the order record commits, leaving its handler unable to find the order and dropping the event. Out-of-order events can also produce conflicting local transitions, even when the provider’s payment state is correct.
Why a stateless mock cannot enforce these transitions
A stateless mock maps request patterns to configured responses. It does not persist a payment record or change that record when a capture request arrives. Consequently, a later refund request has no captured state to be checked against. This is an architectural limit. A more detailed response fixture cannot supply the missing memory.
A capture followed by a payment lookup makes the distinction concrete. The mock’s GET response remains whatever its fixture specifies, perhaps authorized, rather than reflecting the capture that just occurred. Configuring the lookup to return captured merely changes the canned answer. A test can thus pass even when the application refunds an uncaptured payment or reads a state that has nothing to do with the operation it just performed.
The same gap prevents enforcement of refundable balances and idempotency. Without a refund history, the mock cannot reject a second refund that exceeds the remaining amount. Without operation history, it cannot distinguish a replay from reuse of a key across different operations or evaluate a repeated logical operation submitted under another key. Canned webhook payloads have the same limitation. They can exercise a handler in isolation, but they cannot establish that an event was emitted because a legal payment transition occurred.
What a stateful payment simulator must actually do to enforce the machine
A stateful simulator must keep payment records across calls and check each operation against the record’s current state. Authorization, capture, void, and refund must make changes that later requests can see. Capture must update the stored status to CAPTURED so that a following refund uses that stored state. Illegal operations must return structured errors that have the provider’s error shape. A generic rejection is insufficient for testing application error handling.
Balance tracking must be part of that transition logic. Every partial refund must reduce the refundable balance, and concurrent requests must remain constrained by the same remaining amount. An over-refund must receive a stable error. Refunds must also appear as new linked ledger rows rather than overwriting the original charge, so tests can inspect the relationship between charges, partial captures, and refunds.
Idempotency needs a separate record of operations that have been repeated. When a request repeats an idempotency key, the simulator must return the same response and preserve the operation’s existing financial effect. That behavior lets tests distinguish response delivery from financial execution. It also makes retry assertions meaningful, because the test can inspect whether the payment changed or another ledger entry appeared.
PayGate integration test issue #315 supplies concrete acceptance criteria for this model. It requires the happy path to reach a “settled-eligible captured state,” a declined response to produce the correct reason code, and refund after capture to produce correct ledger entries. Issue #321 makes the refund invariant explicit. A full refund marks the payment refunded and zeroes the refundable balance, a partial refund leaves the correct remainder, and an over-refund is rejected.
How to write integration tests for payment APIs where order of operations matters
The test suite should be organized by transitions. Each scenario should start from a known state, run an operation or sequence, and assert the payment and ledger state it leaves behind. The simplest lifecycle is tokenize, authorize, and full capture. It should reach CAPTURED, account for the fully captured amount, and expose a refundable balance equal to that amount. Partial capture should assert the captured amount and corresponding refundable balance, with automatic cancellation of the remaining authorization where that is the modeled behavior.
Refund and void scenarios extend that baseline. A full refund should reach REFUNDED, zero the refundable balance, and create a linked ledger entry. Multiple partial refunds should reduce the balance after each operation without exceeding the captured amount. An authorize-then-void scenario should release the authorization and reach VOIDED, after which capture and refund are rejected. The Adyen guidance extends modification coverage to multiple partial captures, failed capture, reversal, and pre-authorization adjustment, including the webhook expected for each.
Negative-path tests should then try transitions that the rules forbid. Refund before capture must return the provider-shaped error and leave the payment state unchanged. A duplicate full capture must leave only one successful capture, while capture after void must be rejected. Over-refund tests should cover both a request above the captured amount and a later request above the remaining balance. The decisive assertions are the financial effect and recoverable local state.
Concurrency tests should apply those invariants to overlapping requests. Two full capture requests against the same authorization should be serialized so that exactly one succeeds. When two partial refunds together ask for more than the refundable balance, one request should be accepted and the other should get a stable conflict or updated-balance response. The marketcore Phase 6 issue names “fifty pays charge once” and “cancel race” tests as integration gate conditions.
Idempotency tests should replay a capture with the same key and assert an unchanged payment and the same response. Replaying a refund should leave a single refund ledger entry. Submitting the same logical operation under different keys should instead produce distinct operations, with the second constrained by the payment state. Webhook tests should verify AUTHORISATION, CAPTURE, REFUND, and CANCEL_OR_REFUND, or CANCELLATION for explicit cancel-only flows. Replayed events must not double-process, and reordered events must still lead the application to the correct final state.
Fault injection as a required part of payment simulator testing, not an optional layer
Network timeouts mid-capture, gateway failures, late responses, and failed webhook deliveries can leave it unclear whether an operation has finished. That uncertainty drives retries and duplicate event delivery. Recovery must preserve the same financial invariants. Fault injection tests whether the application respects the machine when a successful operation is not followed by a successful response.
A timeout after the gateway accepts capture is the critical case. The application has no response, but the financial operation has already happened. Its recovery must query by a stable payment reference or retry the existing capture with the same operation key. The test must assert exactly one captured financial effect and a recoverable local state.
Tests with gateway 5xx responses should also check that retries keep idempotency intact when an operation may already have succeeded. Degraded latency should exercise clients that retry before the first response arrives. Injected 429s should verify backoff that limits further attempts. Dropped webhook deliveries should provoke retries without duplicate processing.
Configurable latency, injected 429s and 503s, and dropped webhook deliveries allow the same recovery paths to run repeatedly in CI. The simulator enforces the payment rules while faults place the application under conditions that tempt it to violate them. A suite covering only clean responses leaves that recovery behavior untested.
Running the simulator in CI without live credentials or rate limit exposure
Automated runs against a live sandbox must stay within the provider’s rate limits and have test-mode credentials on CI workers. Parallel runs amplify both constraints, while imperfect cleanup can leave shared sandbox state for later tests. These dependencies make failures harder to reproduce. A local or containerized stateful simulator removes live credential exposure and provider throttling from the integration suite without discarding transition enforcement.
The marketcore issue describes how to set up CI this way. Automated integration tests run against a local stateful simulator whose state is controlled and pre-seeded, while real sandbox credentials are kept for a manual verification gate before launch. Meaningful seed data reduces setup work. Payment records, customers, and refund histories let tests begin at the state relevant to their assertion, saving the work of rebuilding every preceding step. Each test must also be able to start from a clean state or a specific seeded scenario without affecting parallel runs.
Isolation alone does not make concurrency tests reproducible. The simulator must also support deterministic execution at the concurrency required by concurrent charge attempts and cancel races. Otherwise, the suite cannot reliably exercise the conditions its gate is intended to test. A shared sandbox adds network latency and rate limits that get in the way of those scenarios. Local control keeps faults injected on purpose separate from failures that happen by accident in the test environment.
A simulator can enforce a stale model perfectly while the provider’s API has changed. Continuous probing of the live API before each simulator release is the mechanism for detecting that drift, separate from application CI runs. Without it, green tests can certify the wrong contract. Stateful simulation makes sequencing testable, but current provider behavior must remain the standard against which the simulator is judged.