How to test dunning: failed payment retries, decline codes, and subscription states
To test dunning, run each failed renewal on a controlled clock and assert the retry or stop decision per decline code, the email sent, the subscription state, and the ledger entries. The check that catches the least visible defect writes off a past-due invoice and confirms that the write-off does not restore access or restart renewal charges.
Where failed-payment recovery loses money
The common dunning defect is a schedule that treats every decline the same way. A renewal fails, and the job retries on days 1, 3, 5, and 7 whatever the issuer said. A lost card gets four more authorisation attempts, a customer who updated the card in the portal is charged on the old one, and a subscription that should be unpaid keeps its access for a month.
Each of these reaches production because a processor sandbox returns one decline per test card, and nobody runs a subscription through 30 days of failures before release. The consequences land in three places: renewal revenue that a new payment method would have recovered, network fees and rule breaches from reattempts the card scheme does not permit, and a revenue export that books income for periods the customer never received. The failure patterns and card network rules for billing platforms are on the billing segment page; this guide gives the procedure.
Procedure
1. Classify every decline before a retry is scheduled
Test the decision function in isolation first, with recorded or synthetic error bodies. The fields below are the documented attributes of one processor's error object (checked on 2026-09-22): code, decline_code, advice_code, network_advice_code, and network_decline_code. The same processor stops automatic retries after nine hard decline codes, listed in the set below.
The reattempt limit comes from Visa Business News of 3 September 2020, which covers the Canada, Europe, Latin America and Caribbean, and U.S. regions from 17 April 2021: no reattempt after Category 1, up to 15 reattempts in 30 days after Category 2, and no reattempt with the same account number after response code 14. The bulletin does not say when the 30 days start, so our counter uses a rolling window, the stricter reading. Mastercard publishes its retry rules in its Transaction Processing Rules, which could not be retrieved on 2026-09-22; take Mastercard limits from your acquirer's current notice.
import json
from datetime import datetime, timedelta
# Codes the processor documents as hard declines: no automatic retry without a new payment method
HARD_DECLINES = {
"incorrect_number", "lost_card", "pickup_card", "stolen_card",
"revocation_of_authorization", "revocation_of_all_authorizations",
"authentication_required", "highest_risk_level", "transaction_not_allowed",
}
VISA_REATTEMPT_LIMIT = 15 # Category 2: up to 15 reattempts in 30 days
VISA_WINDOW = timedelta(days=30) # rolling window, our conservative reading
def decide(error, reattempts: list, now: datetime, brand: str) -> str:
"""Map a PaymentIntent last_payment_error to one dunning action. Never raises."""
if not isinstance(error, dict):
return "review" # HTML page, empty body, or truncated JSON
if error.get("type") != "card_error":
return "retry_same_key" # api_error or timeout: repeat with the same idempotency key
codes = {error.get("code"), error.get("decline_code")}
if "authentication_required" in codes:
return "ask_customer_to_authenticate"
if codes & HARD_DECLINES or error.get("advice_code") == "do_not_try_again":
return "stop_ask_new_method"
if brand == "visa" and error.get("network_decline_code") == "14":
return "stop_ask_new_method" # never reattempt with the same account number
if error.get("advice_code") == "confirm_card_data":
return "stop_ask_card_update"
recent = [t for t in reattempts if isinstance(t, datetime) and now - t < VISA_WINDOW]
if brand == "visa" and len(recent) >= VISA_REATTEMPT_LIMIT:
return "stop_limit_reached"
return "retry"
def run(path: str) -> int:
try:
with open(path, encoding="utf-8") as fh:
cases = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"FIXTURE ERROR: {exc}")
return 1
if not isinstance(cases, list):
print("FIXTURE ERROR: expected a list of cases")
return 1
now = datetime(2027, 3, 31, 12, 0)
failures = 0
for case in cases:
if not isinstance(case, dict):
print(f"FAIL malformed case: {case!r}")
failures += 1
continue
days = case.get("reattempt_days_ago", [])
prior = [now - timedelta(days=d) for d in days if isinstance(d, (int, float))]
got = decide(case.get("error"), prior, now, case.get("brand", ""))
status = "ok" if got == case.get("expect") else "FAIL"
failures += status == "FAIL"
print(f"{status:4} {case.get('case')}: expected {case.get('expect')}, got {got}")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(run("dunning_cases.json"))The fixture file dunning_cases.json holds synthetic cases. The value "14" assumes your processor passes the Visa response code in network_decline_code; confirm the format in its sandbox.
[
{"case": "soft decline, 3 reattempts", "brand": "visa", "reattempt_days_ago": [2, 5, 9],
"error": {"type": "card_error", "code": "card_declined", "decline_code": "insufficient_funds", "advice_code": "try_again_later"},
"expect": "retry"},
{"case": "lost card", "brand": "visa", "reattempt_days_ago": [],
"error": {"type": "card_error", "code": "card_declined", "decline_code": "lost_card"},
"expect": "stop_ask_new_method"},
{"case": "response code 14 on a generic decline", "brand": "visa", "reattempt_days_ago": [],
"error": {"type": "card_error", "code": "card_declined", "decline_code": "generic_decline", "network_decline_code": "14"},
"expect": "stop_ask_new_method"},
{"case": "authentication required", "brand": "visa", "reattempt_days_ago": [],
"error": {"type": "card_error", "code": "authentication_required", "decline_code": "authentication_required"},
"expect": "ask_customer_to_authenticate"},
{"case": "16th attempt in 30 days", "brand": "visa", "reattempt_days_ago": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 29],
"error": {"type": "card_error", "code": "card_declined", "decline_code": "insufficient_funds", "advice_code": "try_again_later"},
"expect": "stop_limit_reached"},
{"case": "processor 500", "brand": "visa", "reattempt_days_ago": [],
"error": {"type": "api_error", "code": null},
"expect": "retry_same_key"},
{"case": "gateway returned HTML", "brand": "visa", "reattempt_days_ago": [],
"error": "<html>502 Bad Gateway</html>",
"expect": "review"}
]The authentication_required branch matters for EU cards: an off-session retry cannot pass strong customer authentication, so the product has to bring the customer back on session.
2. Drive the subscription lifecycle on a test clock
Run the whole schedule in the processor sandbox with simulated time. The requests below follow the documented test clock API and the published decline test tokens (both checked on 2026-09-22). pm_card_chargeCustomerFail attaches to a customer and fails every later charge, so no card number crosses your code and no cardholder data enters the test environment. The clock starts on 1 March 2027, 00:00 UTC.
POST /v1/test_helpers/test_clocks HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_REPLACE_ME
Content-Type: application/x-www-form-urlencoded
frozen_time=1803859200&name=dunning-unpaid-pathPOST /v1/customers HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_REPLACE_ME
Content-Type: application/x-www-form-urlencoded
test_clock=clock_REPLACE_ME&email=dunning-01%40your-verified-domain.example&payment_method=pm_card_chargeCustomerFail&invoice_settings[default_payment_method]=pm_card_chargeCustomerFailPOST /v1/subscriptions HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_REPLACE_ME
Content-Type: application/x-www-form-urlencoded
customer=cus_REPLACE_ME&items[0][price]=price_MONTHLY_20_USD&trial_end=1805068800The trial ends on 15 March 2027 and the first renewal of 20.00 USD fails. In the sandbox, configure a custom schedule of retries 3, 5, and 7 days after the previous attempt, and "mark the subscription as unpaid" as the final action; the processor allows up to three custom retries. A monthly subscription can advance at most two billing intervals per call, so one step reaches 1 April 2027:
POST /v1/test_helpers/test_clocks/clock_REPLACE_ME/advance HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_REPLACE_ME
Content-Type: application/x-www-form-urlencoded
frozen_time=1806537600Wait for test_helpers.test_clock.ready, then assert: the subscription is unpaid, the latest invoice is still open, no more than four charges exist on the invoice, and your product revoked access at the transition. Repeat with the final action set to cancel, and again with it set to leave the subscription past_due.
3. Check dunning emails against the invoice state
The processor sends a failed payment email after each failed payment when the setting is on, and an expiring card email one month before the default card expires. In a sandbox it sends them only to addresses on your verified email domain or to team members, so an email test against any other address passes with no email sent. For each clock step, assert one email per failed attempt, none after a successful payment, and none after cancellation.
4. Make every retry idempotent
A retry job that times out and runs again must not charge twice. Derive the idempotency key from the invoice and the attempt number, and run the concurrency cases in the payment idempotency guide.
5. Replay account updater results
Visa Account Updater returns new account numbers, new expiration dates, closed account advices, and contact cardholder advices. Visa requires merchants to apply updates within five business days of receipt from the acquirer, and within two business days of receipt from the service for acquirer or processor token vaults (checked on 2026-09-22). Mastercard runs a comparable Automatic Billing Updater. The processor emits payment_method.automatically_updated for network updates, and its card update notes state that the card fingerprint changes when the card number changes. Send that event mid-cycle and assert that the next retry uses the new card, that a stop caused by response code 14 lifts only for the new number, and that a closed account advice stops the schedule.
6. Assert the ledger and revenue effects
IFRS 15 paragraph 31 recognises revenue as the service transfers, paragraph 108 accounts for receivables under IFRS 9, and paragraph 113(b) requires impairment losses on receivables from customer contracts to be disclosed (Regulation (EU) 2016/1905, checked on 2026-09-22). The table names are ours.
-- Check 1: no invoice collected more than its amount (duplicate retry charges)
SELECT i.id, i.amount_minor, SUM(c.amount_minor) AS collected_minor
FROM invoices i
JOIN charges c ON c.invoice_id = i.id AND c.status = 'succeeded'
GROUP BY i.id, i.amount_minor
HAVING SUM(c.amount_minor) > i.amount_minor;
-- Check 2: revenue recognised for a service day on which the customer had no access
SELECT r.invoice_id, r.service_date, r.amount_minor
FROM revenue_entries r
JOIN invoices i ON i.id = r.invoice_id
WHERE r.account = 'subscription_revenue'
AND NOT EXISTS (
SELECT 1 FROM entitlement_periods e
WHERE e.subscription_id = i.subscription_id
AND r.service_date >= e.access_from
AND r.service_date < COALESCE(e.access_to, DATE '9999-12-31'));
-- Check 3: write-offs posted anywhere other than the impairment account in your policy
SELECT p.invoice_id, p.account, p.amount_minor
FROM ledger_postings p
WHERE p.source = 'invoice_marked_uncollectible'
AND p.account <> 'receivable_impairment';Each check returns zero rows on a correct run. Check 2 encodes our test design, and your finance lead confirms the access policy it assumes. Settlement-side matching is in the ledger reconciliation guide.
Pitfalls outside vendor documentation
A write-off reactivates the subscription. For a past_due subscription, paying the latest invoice or marking it uncollectible moves the subscription to active. A month-end job that writes off old invoices through that call restores access without payment, and the next renewal charges a card that already failed. Run the write-off job on the past_due fixture and assert that entitlements and the renewal schedule do not change.
Scheduled retries count without charging. After a hard decline, retries stay scheduled and attempt_count keeps rising, but no charge is created until a new payment method appears. A reattempt counter or a "we tried your card again" email built on attempt_count overstates the attempts the issuer saw. Count executed charges per card.
The update link dies at unpaid. The hosted page link in failed payment emails is invalid once the subscription becomes canceled, incomplete_expired, or unpaid. A customer who opens a week-old email after the transition cannot pay. If your product sends its own reminders after the transition, assert that they link to a page where an unpaid customer can still pay.
Updates arrive after the next retry. The acquirer forwards updater responses within two business days, and the merchant then has up to five business days to apply them, so a retry scheduled 3 days after the failure can still run on the old card. Test a retry that runs before the update is applied, and the recovery path when the update lands a day later.
Test matrix
| Case | Decline input | Expected decision | Expected state after final retry | Ledger check |
|---|---|---|---|---|
| Insufficient funds | insufficient_funds, try_again_later | Retry on schedule | unpaid, canceled, or past_due per setting | One charge at most per paid invoice |
| Lost or stolen card | lost_card or stolen_card | Stop, ask for a new method | Same as the configured final action | No charges after the decline |
| Visa response code 14 | network_decline_code 14 | Stop for that account number | Retry resumes only on a new number | No charge on the old number |
| Authentication required | authentication_required | Email the customer to authenticate | Open invoice until the customer acts | No off-session charge |
| Sixteenth attempt in 30 days | Any Visa Category 2 decline | Stop at the limit | Final action applied | Charge count at most 15 reattempts |
| Processor timeout | api_error or no body | Repeat with the same key | Unchanged | One charge per invoice |
| Account updater event | payment_method.automatically_updated | Retry on the new card | active after payment | Payment matched to the open invoice |
Write-off during past_due | Invoice marked uncollectible | No access restored | Policy state, never silent active | Posting to receivable impairment |
Proration on plan changes during dunning is covered in the prorated billing testing guide, and we run this matrix as part of subscription billing testing.
Related
Segments
Guides
- How to test prorated billing on upgrades, downgrades, and cancellations
- How to test payment idempotency
- How to test ledger reconciliation against settlement files and bank statements
- How to test SCA exemptions: low value, TRA, and trusted beneficiaries
- How to test webhook delivery from BaaS and payment providers
Questions
What is a hard decline?
What is Visa Account Updater?
What is involuntary churn?
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing