Fintech
Key points

Key points

  1. 1Send the same refund request twice with an identical idempotency key and assert that exactly one refund exists afterward.
  2. 2Test partial refunds that sum to the full charge, then confirm any further refund attempt is rejected with a clear over-refund error.
  3. 3Inject timeouts and retries at each step (agent decision, provider call, webhook receipt) and check that the ledger stays consistent.
  4. 4Use property-based checks to prove the total refunded amount for any charge never exceeds the original captured amount under any sequence of operations.

Model the refund as a constrained ledger before you write tests

A refund agent is not a single API call. It is a decision loop that reads a charge, computes a refund amount, calls a payment provider, and then waits for asynchronous events to confirm what happened. Every one of those steps can be repeated, reordered or lost. Before you write a test, write down the invariant your system must preserve, because the invariant is what the tests will check.

The invariant for refunds has three parts. First, each refund request carries a client-generated idempotency key, and repeating that key must never create a second refund. Second, the sum of all successful refunds for a charge must never exceed the captured amount. Third, every state transition (requested, pending, succeeded, failed) must be replayable without changing the final ledger.

That framing matters because the failure modes are financial, not cosmetic. A handler that returns HTTP 200 while inserting a second refund row is worse than a handler that returns 500 and forces a retry: the first quietly over-refunds, the second is merely slow. As one webhook testing guide puts it, status codes prove delivery acknowledgment, not financial correctness (qaskills.sh).

Payment providers give you the two primitives you need. Stripe stores idempotency keys on POST requests so a retried request returns the original response rather than creating a new object (Stripe idempotent requests). PayPal uses the PayPal-Request-Id header for the same purpose and notes that when two simultaneous requests arrive with the same header, it processes the first and may fail the second (PayPal idempotency guidelines). Your tests must cover both behaviors: same key sequentially, and same key concurrently.

The subtler problem is key generation. A common bug is generating a fresh UUID on each button click or each agent turn, which defeats idempotency entirely; the client has to persist and reuse the key for a given refund intent (Stack Overflow discussion). For an agent, that means the key should be derived from the intent (charge ID plus a stable refund request ID), not minted at call time.

Build the test matrix before writing assertions

Treat refund testing as a matrix of request shapes against system states. The table below is a practical starting set. Each row should become at least one automated test, and the "failure it catches" column is what you assert against, not just the HTTP status.

ScenarioSetupAssertionFailure it catches
Identical duplicateSame key, same body, sent twiceOne refund object, second response identicalDuplicate refund created
Conflicting duplicateSame key, different amountRejected (4xx) with no side effectPayload tampering via reused key
Concurrent duplicateTwo requests, same key, parallelOne refund, one conflict or cached responseRace condition in key check
Partial sequence30 + 70 on a 100 chargeSum equals 100, both succeedAggregation error
Over-refund30 + 80 on a 100 chargeSecond request rejectedOver-refund
Full then partial100 then 10 on a 100 chargeSecond rejectedMissing remaining-balance check
Timeout on providerProvider times out, then succeedsRetry does not double refundBlind retry after unknown outcome
Lost webhookRefund succeeds, event never arrivesReconciliation job repairs stateSilent ledger drift

Two rows deserve special attention. The conflicting duplicate (same key, different payload) is the attack surface of any idempotency scheme: a system that returns the cached response without checking the request body lets a caller reuse a key to mask a different, larger refund. The concurrent duplicate is where most implementations fail in practice, because the check-then-insert pattern has a gap between the check and the insert. The fix is a unique database constraint on the idempotency key, returning a 2xx for the duplicate rather than an error, a pattern documented for webhook processing (Hookdeck).

CREATE TABLE refund_requests (
  idempotency_key text PRIMARY KEY,
  charge_id       text NOT NULL,
  amount_minor    bigint NOT NULL CHECK (amount_minor > 0),
  status          text NOT NULL DEFAULT 'pending'
);

CREATE TABLE refunds (
  id              text PRIMARY KEY,
  charge_id       text NOT NULL,
  amount_minor    bigint NOT NULL,
  provider_id     text UNIQUE
);

With the unique key in place, your duplicate test becomes a real test of the database, not of application logic that might drift.

Test partial refunds and the running total, not just the single call

Partial refunds are where agents get creative and where the ledger gets hurt. A customer returns one item from a three-item order; the agent refunds that line plus tax. Then a second item is returned. The agent must compute the remaining refundable amount from the charge, not from the customer's stated total.

The safest implementation keeps a computed balance: refundable = captured_amount - sum(successful refunds). Every refund request is checked against that balance inside a transaction that locks the charge row. Two tests follow directly. First, a happy-path sequence: on a 100.00 charge, refund 30.00, then 70.00, and assert the refundable balance is zero and both refunds exist. Second, the over-refund: on the same charge, refund 30.00, then attempt 80.00, and assert rejection with an error that names the remaining balance.

That error message is part of the contract. An agent that receives a generic "refund failed" will often retry with a smaller amount, or hallucinate a different charge. Return structured errors with the remaining refundable amount so the agent can correct itself deterministically.

Then test the accounting side. Refunds are not just payment-provider objects; they post to a ledger, and reconciliation systems exist precisely because payments and records drift (HighRadius). Your tests should assert that the sum of refund ledger entries for a charge equals the sum of provider refunds for that charge, and that both are less than or equal to the original charge. Run that assertion after every test in the suite, not just the accounting ones.

Simulate timeouts and retries at every step

The dangerous case is not a failed refund; it is an unknown refund. The provider call times out after the money has already moved. The agent does not know whether the refund exists, and a naive retry creates a second one. PayPal's documentation addresses exactly this: you can retry idempotent calls that fail with network timeouts or 5xx responses, as long as the server still stores the ID (PayPal).

To test it, inject failures at each boundary and assert the end state rather than the intermediate one:

  1. Timeout after the provider accepted the request but before your service recorded the response. Retry with the same key; assert one refund.
  2. Timeout before the provider accepted anything. Retry; assert the refund eventually succeeds exactly once.
  3. Webhook delayed or dropped. A refund succeeded at the provider but your database still says pending. A reconciliation job should query the provider by idempotency key or refund ID and repair the row.
  4. Out-of-order events: refund.created arriving before your own request record is committed. The handler should be order tolerant and produce the correct final state.

That fourth case is explicitly part of a proper payment webhook test matrix, alongside signature checks and replays (qaskills.sh). For refunds specifically, also test the case where the agent's own internal step (for example, updating the order status to "returned") fails after the refund succeeded. You need a compensating action that reverses the partial work without reversing the refund, since the money movement is the one thing you cannot blindly undo. The compensating transaction pattern exists for exactly this situation and warns that undo steps must account for concurrent changes rather than restoring a snapshot (Microsoft Azure Architecture Center).

A practical check: after every fault-injection test, run the same ledger assertion from the previous section. If the sums match and the balance is non-negative, the compensating logic did its job.

Add property-based checks for the total refunded amount

Example-based tests cover the cases you thought of. Property-based tests cover the arithmetic you did not. The core property is simple to state: for any charge with captured amount C, and any sequence of refund attempts, the sum of successful refunds is between zero and C, and never exceeds C.

Property-based testing frameworks generate random inputs and check that the property holds across all of them. The classic catalog of property patterns includes invariants ("some things never change") and idempotence ("doing an operation twice is the same as doing it once"), both of which map directly onto refunds (F# for Fun and Profit). Here is the shape of a refund property suite:

from hypothesis import given, strategies as st

@given(
    charge=st.integers(min_value=1, max_value=10_000_000),
    attempts=st.lists(
        st.tuples(st.text(min_size=1), st.integers(min_value=1, max_value=10_000_000)),
        max_size=20,
    ),
)
def test_refunds_never_exceed_charge(charge, attempts):
    ledger = RefundLedger(captured=charge)
    for key, amount in attempts:
        ledger.request_refund(idempotency_key=key, amount=amount)
    total = ledger.total_successful_refunds()
    assert 0 <= total <= charge

Three properties are worth encoding explicitly:

  • Bound. Total refunded is never negative and never exceeds the captured amount. This catches rounding errors, currency-conversion mistakes and double counting.
  • Idempotence. Replaying any accepted request with the same key leaves the total unchanged. Generate a random valid request, apply it twice, assert the total after one equals the total after two.
  • Additivity across splits. For a charge of C, any partition of the refundable amount into parts that sum to C produces the same final total as a single full refund. This catches state bugs where sequential partials accumulate differently from one full refund.

Add a fourth property for the agent's own output: for any charge and any natural-language refund instruction, the amount the agent proposes is either zero or at most the remaining refundable balance. That converts "does the agent behave" into a checkable arithmetic property rather than a vibe.

Decide pass or fail with a worked example

Numbers make the decision rule concrete. Take a charge of 100.00 USD captured in minor units, so 10,000. Run the matrix above and you should see the following end states:

TestAttempts (minor units)Expected successful totalExpected rejected
Duplicate3,000 twice, same key3,000second attempt, as duplicate
Partial sequence3,000 then 7,00010,000none
Over-refund3,000 then 8,0003,000second attempt
Full then partial10,000 then 1,00010,000second attempt
Timeout retry4,000, provider timeout, retry 4,0004,000none
Concurrent split6,000 and 6,000 in parallelat most 10,000at least one conflicting request

The decision rule for your CI gate is short: a refund suite passes only if every row ends at its expected total, no row exceeds 10,000, and the ledger-to-provider reconciliation matches for every charge touched. If any row over-refunds, the suite fails regardless of how many other tests passed, because over-refunding is the failure that costs real money and is hardest to claw back.

One more practical rule: do not assert on exact error strings from your provider. Assert on your own structured error codes and on the resulting ledger. Providers change messages; your invariants should not.

Where a multi-model agent setup helps the test harness

Refund agents often sit in front of more than one model, because you may want a fast model for intent parsing and a stronger one for edge-case reasoning about returns policy. Testing that arrangement means running the same scenario matrix against each model path and diffing the outcomes. A single gateway simplifies this: Quanta Inference exposes text, image, video, audio and embedding models through one OpenAI-compatible API key with prepaid US-dollar pricing, so your test harness can swap models with a configuration change rather than an integration rewrite.

If your team is designing the refund workflow itself, including where human approval gates belong before an irreversible money movement, Quanta Consulting offers a free 30 minute discovery call to map your goals to a concrete architecture.

FAQ

FAQ

How many duplicate requests should I send in a test?

At least two, sequentially, and a separate pair in parallel. The sequential pair proves your idempotency record works on the normal path. The parallel pair proves there is no gap between checking the key and writing the refund, which is the failure mode that production traffic actually triggers.

What should happen when the same idempotency key arrives with a different amount?

Reject it. The server should store a hash of the request body alongside the key and compare on each request that reuses the key. Returning the cached response for a mismatched payload lets a caller disguise a larger refund behind an old key, which is a serious financial control gap.

How do I test the case where the provider succeeded but my service never heard back?

Force the timeout after the provider accepts, then run your retry with the same key and assert a single refund exists. Separately, simulate a lost webhook and assert that your reconciliation job queries the provider and repairs the local row, including the refund status and ledger entry.

Should partial refunds be tested as one property or many example tests?

Both. Example tests document the expected behavior for reviewers and catch obvious regressions. Property-based tests generate sequences of partial attempts that you would never write by hand and check the invariant that the total never exceeds the captured amount, which is where rounding and accumulation bugs hide.

How do I stop an agent from proposing an over-refund in the first place?

Give the agent the remaining refundable balance as a tool output rather than a prompt instruction, and validate its proposed amount against that balance before calling the provider. Then encode a property test that any proposal is either zero or at most the remaining balance, so policy drift in the model shows up as a failing test rather than a refund incident.

Sources

Sources

  1. Payment Webhook Testing: Idempotency, Replay, and Out- ...
  2. Idempotent requests | Stripe API Reference
  3. Idempotency
  4. How to ensure that an Idempotency Key is the same in ...
  5. How to Implement Webhook Idempotency
  6. Automated Payment Reconciliation Software
  7. Compensating Transaction Pattern - Azure Architecture ...
  8. Choosing properties for property-based testing
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles