Skip to content

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.

Python
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.

JSON
[
  {"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.

Request
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-path
Request
POST /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_chargeCustomerFail
Request
POST /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=1805068800

The 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:

Request
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=1806537600

Wait 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.

SQL
-- 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

CaseDecline inputExpected decisionExpected state after final retryLedger check
Insufficient fundsinsufficient_funds, try_again_laterRetry on scheduleunpaid, canceled, or past_due per settingOne charge at most per paid invoice
Lost or stolen cardlost_card or stolen_cardStop, ask for a new methodSame as the configured final actionNo charges after the decline
Visa response code 14network_decline_code 14Stop for that account numberRetry resumes only on a new numberNo charge on the old number
Authentication requiredauthentication_requiredEmail the customer to authenticateOpen invoice until the customer actsNo off-session charge
Sixteenth attempt in 30 daysAny Visa Category 2 declineStop at the limitFinal action appliedCharge count at most 15 reattempts
Processor timeoutapi_error or no bodyRepeat with the same keyUnchangedOne charge per invoice
Account updater eventpayment_method.automatically_updatedRetry on the new cardactive after paymentPayment matched to the open invoice
Write-off during past_dueInvoice marked uncollectibleNo access restoredPolicy state, never silent activePosting 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.

Questions

What is a hard decline?

A hard decline is a card authorisation decline that the issuer will not approve on a later attempt with the same card details, for example a lost, stolen, or closed card or an invalid account number. Visa rules for Canada, Europe, Latin America and the Caribbean, and the United States do not permit a merchant to reattempt a Category 1 decline, Issuer will never approve.

What is Visa Account Updater?

Visa Account Updater (VAU) is a Visa service that passes new card numbers, new expiration dates, closed account advices, and contact cardholder advices from participating issuers to merchants that store cards on file. Visa requires a merchant to apply the updates to its customer database within five business days of receipt from its acquirer.

What is involuntary churn?

Involuntary churn is the loss of subscribers whose subscription ends after a failed payment, such as an expired card, insufficient funds, or a closed account, while the customer still intended to pay. Payment retries, card account updater services, and requests to update the payment method are the controls a billing system uses to recover these subscribers before cancellation.

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