Dunning and payment retries¶
A renewal payment that ends as FAILED puts the order into dunning: the order is set to failed and a new charge attempt is queued for the next date in the dunning plan. Frisbii and MobilePay do no dunning of their own, so every retry is ours.
The dunning plan¶
PaymentRetryDateService picks the next date from a fixed set of days when Danish customers are most likely to have money in the account:
- The last weekday of the current month and of the next month (payday)
- Child benefit days: 20 April, 20 July, 20 October and 20 January, moved to the Monday when they fall on a weekend
The earliest of those dates that is still in the future wins. Attempts run at 10:00 UTC.
Add your own dates with the subscribed_payment_retry_dates filter:
add_filter( 'subscribed_payment_retry_dates', function ( array $dates, DateTime $today ): array {
$dates[] = new DateTime( $today->format( 'Y-m' ) . '-15 10:00:00' );
return $dates;
}, 10, 2 );
Scheduling¶
PaymentRetryHandler::schedule_failed_payment_retry() runs on payments-hub/payments/schedule-retry and queues an Action Scheduler action (payments_hub_retry_failed_payment) for the order. It stops without queueing anything when:
| Condition | Why |
|---|---|
The payment failed with failure_state: hard_declined | Final for that payment method — a retry fails the same way |
The order is older than the cap (6 months, payment_retry_cap_months) | Too old to keep charging |
| The subscription has no active agreement | Nothing to charge with |
| The order no longer needs payment | Already paid |
| A retry is already queued for the order | One attempt at a time |
When the attempt runs, process_failed_payment_retry() charges the order through woocommerce_scheduled_subscription_payment_{gateway} — the same path a normal renewal takes. Renewal orders only, since that is the only order type we are sure to hold a token for. If that attempt fails too, the failure transition queues the next date, and so on until the order is paid or one of the conditions above stops it.
On the order screen¶
The Betalinger box on a WooCommerce order shows where the order stands before the payment list:
- A queued retry shows the due date, how far away it is, and how many attempts have failed so far.
- An unpaid order with failed payments and nothing queued shows why no further attempt is coming.
A queued action is only reported when it would actually charge the order. Nothing unschedules one when an order is paid by other means, so a paid order can still carry an action that will exit as soon as it runs — the box stays quiet rather than promising a charge.
The box is the subscribed-order-payments web component (see static/), so a change there needs the bundle rebuilt and committed:
Sites pick the built bundle up from static/dist via composer run copy-statics, so it always matches the PHP it ships with.
REST¶
The order screen reads both halves in one request. Requires edit_shop_orders. The retry block only reads — it never schedules anything.
{
"payments": [
{ "id": 4711, "status": "FAILED", "amount": 199.0, "...": "..." }
],
"retry": {
"scheduled": true,
"scheduled_at": "2026-09-30T10:00:00+00:00",
"attempts": 2,
"blocked_reason": null,
"display": {
"title": "Retry scheduled",
"date": "30-09-2026 12:00",
"detail": "in 3 weeks · 2 failed attempts"
}
}
}
retry carries:
| Field | Meaning |
|---|---|
scheduled | Whether an attempt is queued that would actually charge the order |
scheduled_at | When it is due (ISO 8601, UTC), or null |
attempts | Failed payments recorded for the order so far |
blocked_reason | Why no attempt is coming, or null |
display | The same thing as copy for the order box: title, date, detail |
display is built with the payment-hub text domain and wp_date(), so it follows the site's locale and timezone. The box renders it as-is — the compiled bundle carries no translations of its own. The fields above it stay machine-readable.
blocked_reason is only filled in for an unpaid order that has already failed at least once — an order that was never in dunning reports nothing rather than guessing:
| Reason | Meaning |
|---|---|
hard_declined | The last failed payment was hard declined |
order_too_old | The order is past the retry cap |
no_active_agreement | The subscription has no active agreement to charge |
not_a_renewal | Not a renewal order, so a queued attempt would pass without charging |