Skip to main content

Dunning and suspension

Suspension on a delinquency rung​

Withdraw a surface from a delinquent owner with 423 Locked once they reach a configured dunning rung:

Route::middleware('billing.suspend:api')->group(/* … */);

The delinquency clock is a stored timestamp, so lockout keeps working during a provider outage — and nobody is locked out unannounced: billing:dunning:advance (scheduled daily) walks the configured ladder, sending each rung's warning once its day arrives and charging that rung's late fee if you set one.

A late fee on a rung​

A rung of billing.dunning may carry a fee. The advance charges it once for that rung, and the driver decides how it is collected:

  • Stripe adds it as an invoice item to the owner's next invoice.
  • Mollie, whose cycles the package bills itself, opens the fee as an order of its own and charges it as a separate payment once a cycle of the owner has been paid. Charging it earlier would meet the mandate that has just failed. A fee that is refused stays open for the next paid cycle, and it never starts dunning of its own.

A late fee compensates the delay; it is not the price of anything the customer receives. Its document therefore states no tax, as not subject to VAT, and the DATEV export books it as other income. In Germany this is UStAE 1.3 (6). Across the union, whether a payment is the consideration for a supply is decided under union law (CJEU C-90/20, Apcoa Parking Danmark), and a fee that buys nothing is not. A charge that is the price of something the customer uses, such as an extra service, is not a late fee in this sense: it is consideration for that use and is taxed with it.

A fallback before the ladder​

An owner can keep a second payment method as the fallback: a card beside a SEPA mandate, or two cards. When the default cannot pay a cycle, because the provider refuses the charge, a direct debit bounces days later, or the default cannot be charged off-session at all, the package charges the fallback once, before any dunning:

  • Nothing is dunned while the fallback is due: no PaymentFailed, no past due, no notice. The next billing:run charges the fallback.
  • The fallback is charged under an idempotency key of its own, because the provider still remembers the key the default was refused under.
  • A fallback drawn by SEPA direct debit is announced like any other debit and drawn on the day its notice allows.
  • When the fallback pays, the cycle is paid, FallbackPaymentMethodCharged is dispatched and the owner receives FallbackPaymentMethodChargedNotification: the default still comes first for the next cycle, and it just failed.
  • When the fallback cannot pay either, the cycle goes to dunning as it would have without one, and the ladder's retries charge the default again. A cycle tries its fallback once.

The owner chooses the fallback on the account hub's payment-methods screen. An application with screens of its own uses KeepsAFallbackMethod, which refuses the default as its own fallback and a method that belongs to somebody else:

use Pushery\Billing\Contracts\KeepsAFallbackMethod;

if (app()->bound(KeepsAFallbackMethod::class)) {
app(KeepsAFallbackMethod::class)->setFallback($user, $methodId);
}

Only a driver whose cycles the package bills itself can keep this promise, so only the Mollie driver binds the contract. Stripe retries a failed invoice on its own schedule, and there the package changes nothing.

The hard case — a failed card​

For the harder case — payment has actually failed (a past_due/incomplete subscription) — put billing.dunning on the surfaces that need a working card. A browser request is redirected to the payment-recovery screen (so the customer lands on "update your card", not a dead error); an API/JSON request gets 402 Payment Required (configurable via billing.dunning_status). The recovery screen itself is never blocked, so there is no redirect loop. Like the suspension gate, the decision reads only the local subscription row — no provider call on the hot path.

The card the customer adds there becomes the default for their account and for every subscription that names a card of its own, so the provider's next attempt charges it. That attempt comes on the provider's retry schedule, not the moment the card is added; until it succeeds the subscription stays past due.

Route::middleware(['auth', 'billing.dunning'])->group(/* … the surfaces that need a paid, current card … */);

A pause is never treated as delinquency: it does not start the delinquency clock or walk the owner up the ladder.

Using the ladder when your arrears live somewhere else​

The ladder is the richest part of this: several rungs instead of one deadline, and a different rung per surface. All of that is configuration — the rungs, the policy and the cure window read no table at all — and the one class that combines them reads a single fact: since when is this owner behind with this merchant.

That fact comes through ArrearsClock, so an application that keeps its own view of who owes what binds its own implementation and gets the ladder unchanged:

use Pushery\Billing\Contracts\ArrearsClock;

$this->app->bind(ArrearsClock::class, YourOwnArrearsClock::class);
final readonly class YourOwnArrearsClock implements ArrearsClock
{
public function delinquentSince(Model $owner, ?MerchantScope $merchant = null): ?DateTimeInterface
{
return /* … your own reading, never a provider call … */;
}
}

Three things worth knowing before you write one:

  • The clock, not the rung. Which rung an owner stands on is derived from the clock and your configured schedule. Returning a stored level instead would be a second answer to a question that already has one, and the two would part company the first time the schedule changed.
  • Per relationship, never across. The merchant is part of the question, so an owner behind with one merchant keeps the surfaces of every other. Answering from an aggregate over all of them is the mistake the seam is shaped to prevent.
  • Never a provider call. The ladder runs on every gated request; a lockout that needed the payment provider to be reachable would open the gates during an outage.

Pushery\Billing\Testing\ArrayArrearsClock is an in-memory implementation for your own suite, so you can exercise the rungs without standing up a billing database. It fakes the storage and nothing else — the rung still comes from your schedule and the withdrawal still comes from your policy.

With nothing bound, the package's own reading applies and an install that uses the shipped schema behaves exactly as before.


← Back to the documentation index