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