Fintech

How to Test a Cancellation Agent Against Proration Errors

Antonio Evans9 min read

A split-screen desk scene: on the left, a cancellation agent shown as a small robot reviewing a refund ledger; on the right, a plan-change agent reviewing the same ledger with proration entries.
Key points

Key points

  1. 1A single harness can test both a cancellation agent and a plan-change agent if you assert money-movement invariants on a double-entry ledger rather than checking outputs by eye.
  2. 2Property-based generators should produce random cycle lengths, plan prices, start dates and switch days, because the bugs live in the combinations nobody wrote a fixture for.
  3. 3The strongest assertion is the accounting identity: unused-time credit plus new-plan charge equals the net amount moved, and residual rounding goes to a named account.
  4. 4Differential testing, feeding identical inputs to both agents and diffing their ledger entries, catches drift between two agents that are each individually plausible.

Build one harness, not two

The reason cancellation agents and plan-change agents are usually tested separately is organizational, not technical. One team owns churn, another owns expansion revenue, and their test suites grow apart. But look at what the two agents actually do to the money: both read a billing period, compute an unused fraction, post a credit, post a charge, and record a net movement. A cancellation is a plan change whose new price is zero. Once you see it that way, one harness serves both, and coverage you build for one keeps testing the other for free.

Two design rules make the shared harness possible. First, both agents must emit ledger entries through the same internal interface, ideally a post(entries) call that accepts a list of debits and credits. Second, both must return a machine-readable result object (period boundaries, proration numerator and denominator, amounts), never just a customer-facing message. If an agent only knows how to send an email and update a status field, you cannot assert anything about money. The controlled-automation framing in Trusted Data's financial services agent guide makes the same point: bounded tasks need a proof of completion, not just a convincing transcript.

If your agents run on different model providers, keep the harness provider-agnostic by mocking the model boundary and letting each agent's tool calls execute against a real sandbox ledger. Running every model behind a single OpenAI-compatible endpoint, as Quanta Inference does, removes one more axis of difference when you compare agents.

Start with a double-entry ledger you can assert on

A double-entry ledger is a test oracle that writes itself. Every operation must leave total debits equal to total credits, and every transaction must touch at least two accounts with a net of zero. That single property catches the most common proration bug class: a credit issued without a matching charge, or a charge posted twice.

Keep the account chart small and explicit. Five accounts cover both agents:

AccountNormal balanceUsed for
cashdebitmoney actually collected or refunded
deferred_revenuecreditprepaid, not yet earned
revenuecreditearned plan fees
unused_time_creditdebitcontra-revenue from cancellations and downgrades
rounding_residualdebit or creditthe penny left over after allocation

An upgrade on day 20 of a 30-day cycle looks like this in a ledger you can diff:

[
  {"account": "unused_time_credit", "debit": 3000, "credit": 0},
  {"account": "deferred_revenue",   "debit": 0, "credit": 3000},
  {"account": "cash",               "debit": 5000, "credit": 0},
  {"account": "deferred_revenue",   "debit": 0, "credit": 5000}
]

Thirty dollars of unused value on the old plan, fifty dollars charged for the new plan, twenty dollars net collected. The numbers come from the worked example in Recurly's proration guide, where a customer on a $90 plan upgrades to a $150 plan with 10 of 30 days remaining. A cancellation produces the same shape with the new-plan leg removed, which is exactly why one property suite covers both.

Write the invariant as an executable assertion:

def assert_balanced(entries):
    debits = sum(e["debit"] for e in entries)
    credits = sum(e["credit"] for e in entries)
    assert debits == credits, f"unbalanced: {debits} vs {credits}"
    assert all(e["debit"] == 0 or e["credit"] == 0 for e in entries)

The second line forbids a single entry that both debits and credits the same account, a shape that can hide sign errors. Reconciliation tooling vendors make similar accuracy claims for automated matching, with HighRadius describing 99% reconciliation accuracy as a target for their agent-assisted close, but a test ledger does not need a probability, it needs an exact identity.

Generate billing periods nobody thought to try

Example-based tests encode the cases you imagined. Property-based tests encode the rule and let a generator hunt for the counterexample. The classic taxonomy of property shapes, from "different paths, same destination" through idempotence and the test oracle, is laid out in this property-based testing walkthrough, and each shape maps onto a billing rule. Idempotence, for instance, says that replaying the same cancellation event twice must produce one set of ledger entries, not two.

The generators that matter for proration are the ones that vary the shape of time:

  • cycle length in days, drawn from 28 to 366 so February and leap years appear
  • cancellation or switch day, drawn from day 1 to the last day of the cycle
  • old and new plan prices in minor units, including zero for cancellations
  • whether the cycle boundary aligns with a month boundary or not
  • the number of seats, for per-seat plans

Two generators earn their keep immediately. Cycle lengths of 28 to 31 days expose rounding differences between a "days in month" and a "fixed 30-day divisor" policy, and switch days at day 1 and the final day expose off-by-one errors where the agent credits a day it also charges for. The same discipline applies to market data validation, where non-negative spreads and monotonic timestamps are treated as definitional invariants rather than statistical tendencies: if the property fails, the pipeline is broken, not the market.

For each generated case, assert three properties on both agents:

  1. Balance. Total debits equal total credits after every operation.
  2. Conservation. Unused-time credit plus new-plan charge equals the net cash moved, within one minor unit, and any difference lands in rounding_residual.
  3. Monotonicity. Repeating the operation with the same idempotency key produces no additional ledger entries.

The beginner's guide to property-based testing at danicfilip.com is blunt about the trade-off: properties take longer to write than examples and run slower, so restrict them to functions where an invariant exists. Proration math is exactly such a function, a pure calculation over days and prices, which makes it cheap to test at volume.

Handle replay and out-of-order events before you trust either agent

Cancellation and plan changes both arrive as webhook events, and webhooks are delivered at least once. Hookdeck's idempotency guide is explicit that providers operate on an at-least-once guarantee, so your handler will eventually see the same event twice. Your test suite must prove that the second delivery changes nothing.

The test matrix is small but unforgiving. This payment webhook testing guide frames it as four cells: valid versus forged signature, first delivery versus replay, concurrent duplicates, and out-of-order sequences such as a refund arriving before the payment it reverses. Each cell maps to a ledger assertion. A forged signature returns 401 and posts no entries. A replay returns a success status and posts the same entries zero additional times. Concurrent duplicates yield exactly one set of entries, which is why the database needs a unique constraint on the provider event id, and Stripe's idempotent request documentation shows the same idempotency-key pattern at the API layer for your own outbound calls.

Out-of-order events are where the two agents diverge and where the shared harness earns its keep. Send subscription.cancelled before invoice.paid and the cancellation agent should defer the credit until the payment lands. Send the equivalent plan-change pair to the proration agent and it should do the same. If one agent handles the late arrival and the other posts a negative deferred balance, the differential test in the next section flags it even though each agent's own suite is green.

One subtle trap is the idempotency key itself. If the client generates a fresh UUID on every retry, the server sees two distinct operations and the idempotency mechanism protects nothing, as discussed in this Stack Exchange thread on key reuse. Your tests should retry with the same key and confirm the ledger is unchanged.

Diff the two agents on identical inputs

Differential testing compares two implementations that are supposed to agree. Here the inputs are identical: the same customer, the same billing period, the same plan history, the same effective day. Cancellation is modeled as a switch to a zero-priced plan, so both agents should produce the same unused-time credit for the same day. Compare their ledger entries field by field.

def differential(case):
    cancel_entries = cancellation_agent.run(case)
    switch_entries = proration_agent.run(case.as_switch_to_free())
    cancel_credit = credit_on(cancel_entries, "unused_time_credit")
    switch_credit = credit_on(switch_entries, "unused_time_credit")
    assert cancel_credit == switch_credit, (
        f"mismatch on day {case.day} of {case.cycle_days}: "
        f"cancel={cancel_credit} switch={switch_credit}"
    )

Run it over a thousand generated cases and expect a spread of failures early, especially on the boundary days. Treat every mismatch as a specification question first: decide which agent is right, encode that decision as a new property, then fix the other agent. The compensating transaction pattern is the reference for how to structure the fix, since a cancellation credit is by definition a compensating action that reverses earlier work and must account for concurrent changes rather than blindly restoring prior state, as Microsoft's architecture guidance describes and this design pattern write-up summarizes.

When the two agents truly cannot share code, for example because one calls a vendor billing API and the other computes locally, the differential test degrades to a consistency check: generate a case, run it through both, and assert the net cash movement agrees. A recorded test oracle, the seventh property pattern in the F# guide above, catches regressions as each agent evolves independently.

Turn incidents into regression properties

Every proration incident produces a test case, and the discipline is to convert the specific example into a general property so the whole class of bug is covered. The conversion usually takes one of four forms, which is worth stating as a checklist your team applies during incident review.

IncidentExample-based testProperty it becomes
Credit issued twice on webhook retryone test with a fixed event idreplaying any operation with the same key posts zero new entries
Off-by-one on the last day of cycleone test on a 30-day cyclefor all cycles and switch days, credit plus charge equals net movement
Rounding penny lost on a 3-way splitone test with a pennyresidual always lands in rounding_residual and the ledger balances
Timezone shift moved the switch dayone test in UTC-5for all timezones, the effective day equals the calendar day in the customer's zone

The third row deserves emphasis because it is the one accountants notice. Splitting a credit across three revenue categories rarely divides evenly, and every implementation needs a named account for the remainder. A property that says the residual is always posted, and always to that account, is stronger than any number of specific penny fixtures.

Recording the incident as a property also keeps the suite honest over time. Compensating operations and approval gates both change with policy, and the four gate types framework at Digital Applied shows how audit-trail requirements evolve: nine fields captured per gate event, a state diff that auditors can read. Your test suite should assert the gate fields exist too, because a gate that blocks without an audit record is not a control. When an approval gatefront of a refund over a threshold is added, the property becomes "no debit above the threshold posts without a gate record," and the differential test between the two agents runs again.

Where this fits in your release process

Put the property suite in CI at two levels. A fast subset with a few hundred generated cases runs on every pull request and must finish in minutes. A slow sweep with tens of thousands of cases, including the longest cycle lengths and the extreme switch days, runs nightly and on release candidates. Property libraries shrink failing cases to the smallest counterexample, so when the nightly run fails you get a specific day and price pair to debug, and that pair becomes a new example test in the fast suite.

Two practical guardrails keep the harness maintainable. Pin the ledger chart to a small, versioned file, so a new account is a deliberate change that forces every property to be re-read. And keep the model calls out of the property loop wherever possible: test the deterministic calculation and the tool contracts with mocked responses, then run a small number of end-to-end cases against a real model to confirm the agent still picks the right tools. The error-taxonomy list in the Trusted Data guide names timeouts, retries, stale state and hidden tool errors as the failure modes that turn a prototype into an incident, and each one has a ledger consequence you can assert.

For teams building these agents on multiple providers, the model-selection question can dominate the test matrix. An expert team that has run the differential harness across several models can tell you which combinations survive the properties before you commit. That is the kind of review a free 30-minute AI discovery call is designed for: your goals, your ledger, and a tailored recommendation.

FAQ

FAQ

Can I really test a cancellation agent and a plan-change agent with one suite?

Yes, if you model cancellation as a switch to a zero-priced plan. Both agents then share the same inputs (cycle dates, prices, effective day) and the same output shape (a list of ledger entries plus a result object), so the same balance and conservation properties apply to each. Any divergence becomes a differential test failure, which is a feature, not an accident.

What is the single most valuable invariant to assert?

Total debits equal total credits. It is cheap to compute, catches the majority of real proration bugs (double credits, orphan charges, unposted money), and does not require knowing the correct amount in advance. Layer the conservation property on top to catch sign errors and policy mistakes that still balance.

How many random cases do I need to run?

Enough to cover the boundaries plus a broad sweep. A few hundred cases in pull-request CI catches most regressions, and a nightly run of tens of thousands covers the long tail of cycle lengths and switch days. Property libraries shrink failures automatically, so a large run still gives you a small counterexample to debug.

How do webhook retries interact with the ledger?

Providers deliver at least once, so the same cancellation or plan-change event will arrive more than once. Your tests must prove that replaying an event with the same idempotency key posts zero additional entries, which depends on a unique constraint on the provider event id, as described in this webhook idempotency guide.

Where should the rounding remainder go?

Into a named account such as rounding_residual, never left implicit. Splitting a credit across categories rarely divides evenly, and a property that asserts the remainder is always posted to that account, and that the ledger still balances, is stronger than testing specific penny amounts. Review that account monthly so it does not quietly accumulate.

Sources

Sources

  1. AI Agents for Financial Services | Controlled Automation ...
  2. Prorated billing 101: What it is and how it works - Recurly
  3. Automated Payment Reconciliation Software
  4. Choosing properties for property-based testing
  5. Property-Based Testing Meets Financial Data: Turning Market …
  6. The Beginner's Guide to Property-based Testing - Filip Danić
  7. How to Implement Webhook Idempotency
  8. Payment Webhook Testing: Idempotency, Replay, and Out- ...
  9. Idempotent requests | Stripe API Reference
  10. How to ensure that an Idempotency Key is the same in ...
  11. Compensating Transaction Pattern - Azure Architecture ...
  12. Compensating Transaction Design Pattern
  13. Agentic Workflow Approval Gates: Governance Framework
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