Skip to content

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:

cd static && yarn build

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.

GET /wp-json/wc/v3/orders/{order_id}/payments
{
  "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