LarsaSub is launching soon. Purchases and account registration open shortly.

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

Recurring charges

A RecurringCharge is one collection: a period of a subscription, or an amount you plan yourself and collect from the customer's mandate. This page covers what a charge goes through, what you may show and offer the customer, chargebacks and the reminder series. Read the lifecycle before you build any billing screen.

The charge lifecycle

The package plans, announces, collects and settles every charge itself. Your part is to show charges to the customer, offer a pay button only where the package says one belongs, and react to the events.

Planned        planned_on = its collection day          larsasub:plan-charges, or createStandaloneCharge()
    ↓
Collecting     charged_at set, sent to the provider     larsasub:execute-charges
    ↓
Paid           paid_on set                              webhook
   or Failed / Expired / Canceled                       webhook — the customer may now pay it by hand
    ↓
Charged back / refunded                                 later webhooks — a SEPA chargeback can become a follow-up charge

Superseded     a failed first payment replaced by a newer attempt that was paid

A RecurringCharge belongs to a BillableSubscription — or to nothing at all: standalone charges exist (the €0.01 mandate-setup verification is one, and so is every charge you create yourself), and isSubscriptionCharge() / isStandaloneCharge() tell them apart.

A subscription charge is planned on its collection day, not ahead: the row does not exist before that day, so a "next payment" display reads the subscription's getNextBillingDate() rather than looking for a planned charge. A standalone charge exists from the moment you create it.

A subscription charge's amount is the base price plus every active cost line. When cost lines were billed, the composition is frozen in amount_breakdown, and once the charge is paid invoicing turns each breakdown entry into an invoice line.

larsasub:execute-charges collects every due charge from the mandate, with two exceptions. A due charge with nothing left to collect (0,00, for example because credit covered it) is never sent to the provider: the run settles it locally, as planning does, and its paid line says why. A due charge with a negative amount is neither collected nor settled: it is flagged for an operator. A local settlement that throws costs only its own charge: the rest of the run goes on, the command exits non-zero at the end, and the log names the charge to check.

States at a glance

State How you recognise it What the customer can do
Planned charged_at and paid_on empty, no needs_attention Nothing. The scheduler collects it from the mandate on planned_on; it is never payable by hand.
Being collected isLiveAttempt() — claimed, neither paid nor failed. A SEPA debit stays here for days. Wait. Never offer a retry.
Paid paid_on set Nothing is owed. With invoicing on, the invoice exists.
Failed, expired or canceled hasFailedProviderStatus() Pay it by hand through a one-time method, when is_payable says so.
Flagged needs_attention An operator looks at it. Payable by hand only when nothing was created at the provider.
Charged back paid_on stays set, hasStandingChargeback() is true Pay the follow-up charge, when one was created.
Chargeback follow-up isChargebackFollowUp() Pay it by hand: it is payable from the moment it exists, and never collected from the mandate.
Superseded superseded_by_charge_id set Nothing. Its successor carries the money.

What you do, and what you never do

What you do:

What you never do — each of these costs money:

Properties

Models\RecurringCharge (larsasub_recurring_charges). Every column is contract.

Property Description
reference Human-quotable code, {PREFIX}-{8 chars}, or {TENANT}-{PREFIX}-{8 chars} when a tenant reference code applies: FP for a first payment, MS for a mandate verification, RC for every other charge (reference.*_prefix). It opens the payment description Mollie shows. RecurringCharge::parseReference($input) turns what a customer quotes into PREFIX-CODE, tenant prefix stripped
billable_id The Billable registration
billable_subscription_id Owning subscription, or null for a standalone charge
payment_method_id Payment method used. Once a manual payment is started it is the one-time method the customer picked
amount Amount charged, after credit was applied
original_amount Amount before credit
credit_applied Credit consumed by this charge. Only a charge in the credit account's own currency gets any — see Credit
currency Currency code (EUR). A subscription charge takes the subscription's price_currency; a standalone charge takes the one you pass, uppercased and checked against currency.allowed; a chargeback follow-up takes its original's
description Frozen text. On a subscription charge the package composes it: the plan name, in the billable's locale, and nothing else — which period it covers is servicePeriod(), not this. On a standalone charge it is yours
amount_breakdown Composition snapshot (base price + cost lines). Only written when cost lines were billed
vat_rate, vat_treatment, vat_reverse_charge, vat_customer The VAT actually collected, frozen — see Invoicing. On a subscription charge the rate is resolved when the charge is composed, on its collection day (Per-subscription VAT rate). vat_customer is encrypted
platform_fee_amount, platform_fee_type, platform_fee_percentage Connect: the application fee the platform took on this payment — see Connect
notify, notified_at Advance-notice bookkeeping
planned_on The date the charge is due
charged_at When it was claimed and submitted to the provider
paid_on When it settled
mandate_setup True on the €0.01 mandate verification: never a delivery, never payable, never invoiced
provider_mandate_type, provider_mandate_id The mandate it was collected from
provider_payment_id, provider_payment_status, provider_url, status_update_at Provider state. provider_url is the checkout of a payment the customer started
provider_payment_details, provider_refund_details, provider_chargeback_details The provider's raw payment, refund and chargeback data, encrypted — see Refunds and chargebacks
provider_payment_status_raw Reserved for the provider's own status word next to the canonical one; nothing writes it yet
settlement_id The Mollie payout this payment was paid out in, written by larsasub:sync-settlements; $charge->settlement is the payout — see Settlements
needs_attention, attention_message, needs_attention_since Requires an operator — see the attention flag
payable Claim/webhook bookkeeping — not "the customer may pay this"
superseded_by_charge_id The charge that replaced this one
follows_charge_id, origin_chargeback_id On a chargeback follow-up: the charge it collects again, and the chargeback it answers (unique)
origin_reversed_at On a follow-up: set when the chargeback it answers is reversed — an unpaid follow-up is then withdrawn, a paid one settled per dunning.double_paid
reversal_checked_at On a follow-up: when larsasub:execute-charges last asked Mollie whether its chargeback was reversed

Relations: billable, billableSubscription, paymentMethod, providerMandate, invoice, settlement, creditTransactions, supersededBy / supersededCharges, followsCharge / followUps, and dunningCase.

Status methods and transitions

Ask the model, not the columns: several states need more than one column, and some need the provider.

Status methods

$charge->isPaid();
$charge->isNotPaid();
$charge->isSuperseded();
$charge->hasCreditApplied();
$charge->hasFailedProviderStatus();    // failed, expired or canceled
$charge->isLiveAttempt();              // still running at the provider — see below
$charge->isChargebackFollowUp();
$charge->hasStandingChargeback();      // a chargeback on it was not reversed
$charge->receivedAmount();             // cash kept, as a decimal string — see "Counting paid charges"
$charge->hasUnresolvedCollection();    // claimed, no provider answer yet, maybe collecting
$charge->does_need_attention;          // attribute
$charge->is_payable;                   // attribute — see "Manual payment"
$charge->is_continuable;               // the provider checkout is still open
$charge->notPayableReason();           // why it is not payable — see "Outstanding charges"
$charge->breakdownEntries();           // the amount_breakdown, as value objects
$charge->servicePeriod();              // ['start' => 'Y-m-d', 'end' => 'Y-m-d'] or null

RecurringCharge::query()->isLiveAttempt()->get();   // the SQL twin, as a scope

Write every scope as Model::query()->scope(). Several scopes on these models share their name with a public instance method (isLiveAttempt, isPaid, isNotPaid, hasFailedProviderStatus, and the same pattern on BillableSubscription, Subscription, PaymentMethod and CreditTransaction). PHP resolves RecurringCharge::isPaid() against that instance method and fatals with "Non-static method cannot be called statically" — Eloquent's __callStatic, which is what forwards a scope, is never reached. Model::query()->scope() always works; every scope listing in these docs is written that way.

Which period a charge covers

servicePeriod() answers which period a charge covers, as ['start' => 'Y-m-d', 'end' => 'Y-m-d'], or null for a charge that covers none — a standalone charge, a mandate-setup verification. Invoice lines and the payment receipt both take their dates from here, so a page that prints a service period should use it rather than compute one beside it.

It reads the amount_breakdown snapshot when there is one (the outer bound across its rows, so a cost line added mid-period does not shrink the answer to its own prorated window). Otherwise it derives the period containing the planned date from the subscription's start and interval — not "planned date + one interval": a catch-up charge is planned on the day the scheduler noticed it, mid-period.

Calling it per row costs a query per row. Without a snapshot — the common charge, since one is only written when a cost line was billed — the method needs the subscription. On a list (a portal page, an export) eager-load it: RecurringCharge::query()->with('billableSubscription'). The bundled dashboards do, and the value is in their payload as period.

The teardown guard: isLiveAttempt()

isLiveAttempt() is true while a charge is claimed (charged_at written before the provider call) but neither paid nor provably failed. That covers two shapes hasFailedProviderStatus() and the open/pending statuses both miss: a claim with no provider status yet (the process died mid-call; reconciliation resolves it), and a SEPA collection that settles in days.

Every package path that deletes or anonymises a subscription, mandate or billable waits for it to resolve — the cleanup sweeper, terminateFailedSubscription() and prune-billables --orphaned all gate on this one method. Host code that removes such rows must do the same: deleting underneath a live attempt makes the eventual paid webhook skip its work silently, and the money has nothing to be booked against.

(ProviderPaymentModel::constrainToLiveAttempt() is the shared implementation behind the method and the scope, and is @internal — call one of the two.)

The attention flag

needs_attention puts a charge in front of an operator. Setting it writes the model log and dispatches PaymentNeedsAttentionEvent, which mails the company while notifications.company.payment_needs_attention is on. The flag is one slot for every reason, so attention_message says which: the package opens it with a marker constant (RecurringCharge::CREATED_NOTHING_MARKER and the rest — see Public API). Match with str_starts_with(); the text after the marker is English diagnosis, not contract.

$charge->setNeedsAttention('Checked with the customer: wait for the bank.');
$charge->flagFailedCollection($exception, $message);   // after a failed executeCharge() in your own code
$charge->clearNeedsAttention('Settled by phone', requireMarker: null);

Transitions

Transitions are methods on the model, called by the webhook handlers. You do not call them yourself; they are listed so you know what each one does:

$charge->onNotified();                      // advance notice sent
$charge->onCharged($log);                   // submitted to the provider
$charge->onPaid($log, $first);              // settled; activates the subscription if first
$charge->onFailed($first);                  // payable again; a first payment deactivates the subscription
$charge->onExpired($first);                 // the same
$charge->onCanceled($first);                // the same
$charge->onChargeback($chargeback);         // Support\PaymentChargeback; may create a follow-up
$charge->onChargebackReversed($chargeback); // settles the chargeback's follow-up
$charge->onRefunded($refund);               // Support\PaymentRefund

Charges you create yourself

Not every collection belongs to a subscription. An app that bills usage — tokens, API calls, transaction fees — collects a different amount every month against the same mandate. createStandaloneCharge() is the supported way to plan one:

use PetersDevelopment\LarsaSub\Service\RecurringChargeService;

$charge = app(RecurringChargeService::class)->createStandaloneCharge(
    owner: $user,                       // or a Billable registration
    paymentMethod: $directDebit,        // must support recurring payments
    amount: 24.95,                      // the amount that gets COLLECTED
    currency: 'EUR',
    description: 'Usage September 2026',
    plannedOn: now()->addDays(14),
    notify: true,                       // default
    vatRate: null,                      // default: invoicing.vat_rate
);

From there the normal machinery takes over: larsasub:notify-charges announces it, larsasub:execute-charges collects it on planned_on against the billable's mandate, the webhook settles it, and — with invoicing on — a paid charge gets an invoice in the charge's currency, with a single line carrying your description. A failed collection becomes payable by hand like any other charge, and it gets a reminder series when that is on.

Announce it, and plan far enough ahead. notify defaults to true because this is the shape that needs a notice per collection — the amount differs every time, so the one-time notice a subscription gets at activation does not cover it. The mail (notifications.user.charge_announced, see the charge announcement) goes out scheduling.notify_charges_days_before days before planned_on, and only while that date is still in the future: a charge planned for today is never announced and simply collects on the next run.

The amount is what gets collected, exactly like a single payment and unlike a subscription charge: the package never grosses it up for invoicing.prices_include_vat. A later invoice splits it into net + VAT instead. Credit in the customer's wallet is not applied to it. Pass vatRate when this charge is rated differently from invoicing.vat_rate (9% usage next to a 21% subscription).

The mandate is resolved at collection time, not now — so you can plan a charge before the customer has one. If no usable mandate for that payment method exists when execution runs, the charge is flagged needs_attention instead of collected and the operator is notified. Because nothing was created at the provider, the customer can pay it by hand meanwhile.

It is not idempotent, and cannot be. reference is 16 characters and the {TENANT}-{PREFIX}-{8} format already fills them, so it cannot carry a key of yours. A usage job that runs twice creates two charges and collects twice — track on your side what you already planned.

The description is yours and freezes onto the charge and onto the invoice line, so pass it in the billable's locale (same rule as cost lines). It does not reach the provider: the payment description Mollie shows — and therefore the customer's bank statement — is the charge's own reference followed by the month, which is what reconciliation keys on.

What it refuses, each with a RecurringChargeException: a payment method without recurring support, an amount that rounds to €0,00 or less (the amount is rounded to cents first), a vatRate outside 0–100, an empty description or one over 255 characters (rejected, never truncated), and a currency outside currency.allowed. In Connect mode a tenant that is draining a requested disconnect is refused with ConnectDisconnectPendingException before anything is written — see Disconnecting a tenant.

Supersession

A failed first payment is never reused: retryFailedFirstPayment() creates a new charge, and once an attempt is paid every earlier failed attempt of that subscription is marked with superseded_by_charge_id. The full attempt history survives:

$charge->supersededBy;       // the charge that replaced this one
$charge->supersededCharges;  // the charges this one replaced

RecurringCharge::query()->notSuperseded()->get();
RecurringCharge::query()->superseded()->get();

Anything that sums money must filter on notSuperseded() — the successor carries the amount, and counting both double-counts it.

A manual payment of an ordinary failed charge is different: it runs on the same row. The new payment's provider id, status and checkout URL take the place of the failed attempt's, and nothing is superseded.

Manual payment: the recovery path

A failed charge can be paid by the customer through a single-payment method (iDEAL, Bancontact, credit card, PayPal, Apple Pay, KBC/CBC, Belfius, EPS, TWINT). The bundled payments dashboard shows a pay button with a method picker; its route larsasub.my-payments.manual-charge-payment starts the flow.

Manual payment is strictly a recovery mechanism, never pre-payment. A charge the scheduler has not attempted yet is never manually payable, even though its raw payable flag is true — that flag is webhook-retry bookkeeping.

When a charge is payable

is_payable (the accessor RecurringCharge::getIsPayableAttribute()) is true only:

Even then it says no while a payment is still live at the provider, once the subscription has ended, or for a failed first payment. The table below names every reason.

Double payment is prevented by an atomic claim: a row lock plus a re-check of payability and of any active provider payment. While a manual payment is being started or runs, the charge carries payable = false; a payment that fails, expires or is canceled makes it payable again.

A start that fails at Mollie gives the button back only when the failure provably created nothing: a 4xx other than 409, or an error before anything was sent. A timeout, a 5xx or a 409 (Mollie still processing an earlier request with the same idempotency key) may have created the payment, so the charge stays unpayable and is flagged needs_attention with PAYMENT_START_UNCLEAR_MARKER: confirm at Mollie what happened before you clear the flag.

Building your own "pay now" list

Use $owner->payableCharges() (also on the Billable). It carries the accessor's recovery-only contract as a SQL prefilter plus the accessor as the authority — the still-live-at-provider check is provider logic and is not expressible in SQL.

foreach ($user->payableCharges() as $charge) {
    // safe to offer a pay button
}

A hand-rolled where('payable', true)->whereNull('paid_on') is broader than the contract. It surfaces planned charges the scheduler will still collect by mandate, so a pay button on them invites double collection.

Starting a payment from your own code

Headless, gate on is_payable and start the checkout through the provider:

use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;

if ($charge->is_payable) {
    return PaymentProviderFactory::make($charge->paymentMethod->provider)
        ->startManualChargePayment($charge, $ideal);   // a RedirectResponse to the checkout
}

The redirect and webhook routes exist whatever the dashboards are set to. The method argument is optional: without one the first available single-payment method is used.

Which methods the customer can pick. The bundled picker selects isSingle() and then narrows with isEnabled() in simple mode or availableForTenant($tenant) in Connect mode. Hiding a method is not the guard, though: the start endpoints accept payment_method_id from the query string, so the provider service checks the method again before it starts anything — it must support single payments and be active, and in Connect mode the charge's tenant must offer it. The tenant is resolved once per call and shared with the fallback query, so the fallback and the check never disagree about which methods a subscriber may use.

The start refuses with ConnectDisconnectPendingException for a tenant draining a requested disconnect.

Outstanding charges and why a charge is not payable

payableCharges() answers "what may this customer pay right now". It does not answer "what does this customer still owe" — and those differ, most painfully at the end of a subscription. A tenant cancels with a month's notice, the last direct debit bounces, the end date passes and deactivates the subscription: is_payable now returns false for that charge, the pay button disappears and the manual-payment route answers 422. The money is real and self-service cannot reach it.

Two additive APIs make that state visible. Neither changes is_payable or payableCharges() by one row.

RecurringCharge::notPayableReason(): ?NotPayableReason names the branch that said no, and returns null when the charge is payable. It is the same decision tree is_payable reads — is_payable === (notPayableReason() === null), one implementation, so the two cannot disagree.

Enums\NotPayableReason Guard What it means for a host
AlreadyPaid paid_on set Settled (also zero-amount and fully credit-covered charges). Nothing owed.
ChargebackReversed A chargeback follow-up whose chargeback the bank reversed (origin_reversed_at), not paid Nothing owed any more. Seen while a manual payment of it was under way when the reversal arrived; the package withdraws it when that payment ends without money, and gives it back when it succeeds.
NotMarkedPayable payable = false The package is not offering this charge: mandate-setup (€0.01 verification), a manual payment being claimed right now, a manual payment start with an unclear outcome (PAYMENT_START_UNCLEAR_MARKER), or a failed FIRST payment (replaced with a fresh charge rather than reoffered).
PaymentInProgress Charged and still live at the provider The double-payment guard. A SEPA collection sits here for days — tell the customer to wait, never offer a retry.
(none — payable) isChargebackFollowUp() A chargeback follow-up is payable from creation; the guards below never run for it.
NotAttemptedYet No charged_at, no needs_attention Planned; larsasub:execute-charges will collect it by mandate.
SubscriptionMissing Subscription row unresolvable Broken bookkeeping. Needs an admin; say nothing about "your subscription".
SubscriptionInactive Subscription ended/deactivated The dunning case above. Show the amount, name it outstanding, route to support.
PaymentInProgress (second guard) Claimed for collection (charged_at) with no provider answer on the row yet — the call in flight, a run that died, a timeout — unless the failure provably created nothing (CREATED_NOTHING_MARKER) The provider may be collecting it. Wait until larsasub:execute-charges reconciles the claim or an operator resolves the flag; never offer a retry.

Guards run in that order and the first one that trips is reported.

$billable->outstandingCharges() (also on the owner model through IsBillableModel) lists every attempted or flagged, still-unpaid charge — and every unpaid chargeback follow-up — together with that reason — a show list, not a pay list:

foreach ($user->outstandingCharges() as $charge) {
    $reason = $charge->notPayableReason();

    if ($reason === null) {
        // Also in payableCharges(): render the pay button
        continue;
    }

    // e.g. "€12,34 outstanding — contact us"
    $this->renderOutstanding($charge->amount, $reason);
}

Do not wire a payment start to this list. It deliberately contains charges the package refuses to collect again through self-service. Starting a payment for one of those bypasses the double-payment guard: a customer whose collection is still travelling gets debited twice. The button stays bound to is_payable.

The list is a strict superset of payableCharges(), with three deliberate prefilter differences:

Both relations the reason reads (paymentMethod, billableSubscription) are eager-loaded, so asking every row costs no extra queries.

Refunds and chargebacks

This section is the read side: what the package delivers when a refund or a chargeback happened. To make a refund — and to give back credit that was applied to the charge — see Refunds.

Refunds and chargebacks are delivered per occurrence, as value objects, on RecurringChargeRefundedEvent, RecurringChargeChargebackEvent and RecurringChargeChargebackReversedEvent:

use PetersDevelopment\LarsaSub\Support\PaymentChargeback;
use PetersDevelopment\LarsaSub\Support\PaymentRefund;
Value object Properties
PaymentRefund id, amount, currency, status, description, createdAt, settlementId
PaymentChargeback id, amount, currency, status, createdAt, settlementId, reason, reversedAt

Mollie allows multiple partial refunds per payment, so $refund->amount is that refund's amount — never the payment total. The delivery contract is once per refund id when the refund completes (in-flight queued, pending or processing refunds and failed/canceled refunds are never delivered), and once per chargeback id on first appearance.

Webhook redelivery makes at-least-once the only honest guarantee: key your idempotency on the value object's id, and check isBookable() before you book an amount. amount is the provider's own string, passed through unparsed; a value that is not a positive decimal makes isBookable() false. Park such an entry for an operator rather than drop it — the money moved regardless.

A reversed chargeback (onChargebackReversed()) fires its own event and leaves the charge's status (chargeback) and payable flag untouched — the original payment is simply paid again, and hasStandingChargeback() turns false. A chargeback that is already reversed on first sight fires the chargeback event first and the reversal straight after. What the reversal does to a follow-up charge is described below.

The raw, unfiltered provider data stays available on provider_refund_details and provider_chargeback_details, minus detail entries the provider sent without an id — those are dropped before the merge with a logged warning, because the merge keys on that id and every id-less entry would collapse onto the same key. That skip is id-only: an unparseable amount is still delivered, because the money moved.

See Events and Public API for the full contract.

Chargeback follow-ups

A SEPA chargeback (storno) arrives after the payment settled — up to eight weeks later, or thirteen months for an unauthorised debit. The charge keeps paid_on and stops being payable, so on its own the debt for a period you already delivered would be invisible. With dunning.chargeback_follow_up on (the default), the package turns the chargeback into a follow-up charge:

$charge->followUps;                                        // follow-ups of this charge
$followUp->followsCharge;                                  // the charge it collects again
$followUp->isChargebackFollowUp();                         // true
RecurringCharge::query()->chargebackFollowUp()->get();     // and notChargebackFollowUp()
RecurringCharge::query()->openChargebackFollowUp()->get(); // still owed

RecurringChargeFollowUpCreatedEvent fires after commit, once per chargeback.

On by default. Switch it off with LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP=false. A follow-up whose manual payment start died halfway is recovered by larsasub:execute-charges (a reconciliation pass; see Scheduling) — so only with the scheduler running. Turning it on is never retroactive: chargebacks delivered while it was off keep no follow-up.

Which chargebacks qualify

dunning.chargeback_follow_up_methods lists them by provider method id, default ['directdebit']. A card chargeback is a dispute the merchant may still win, often over whether anything is owed at all; add creditcard only if you accept asking for payment while it runs. PayPal disputes run outside Mollie and never arrive.

No follow-up is created for a chargeback that is already reversed when it first arrives, for one whose entry cannot be read or is in another currency, for a mandate verification, or for a chargeback delivered while dunning.chargeback_follow_up was off. Each skip is logged with its reason.

When the bank reverses the chargeback

A bank can give a chargeback back (Mollie's reversedAt) — a card dispute the merchant won, or a SEPA storno the bank corrected. The debt the follow-up stood for is then gone, and what the package does depends only on where the follow-up stands:

The follow-up What happens Event
Nobody is paying it Withdrawn: credit applied to it goes back to the wallet, then it is soft-deleted. Never invoiced. RecurringChargeFollowUpWithdrawnEvent
A manual payment of it is under way Marked (origin_reversed_at) and no longer payable (NotPayableReason::ChargebackReversed). Withdrawn when that payment fails, expires or is canceled — instead of the usual failed/expired/canceled event, which would offer a "pay again" for a debt that is gone. …WithdrawnEvent then
Paid Paid twice. Given back per dunning.double_paid (below). The follow-up and its invoice stay: they are the customer's document for the period. RecurringChargeFollowUpDoublePaidEvent

A chargeback that is already reversed when it first arrives gets no follow-up at all.

How the package learns of a reversal. Mollie sends no webhook for one, so the package asks:

Past the window, a reversal is seen only when something else fetches the original (another webhook for it, a refund requested on it, a plan change). The same goes for a chargeback that got no follow-up (a method outside dunning.chargeback_follow_up_methods — card, by default): nothing of the package's depends on its reversal, and RecurringChargeChargebackReversedEvent for it waits for such a refresh.

Paid twice — dunning.double_paid. The amount is what the follow-up collected plus the credit applied to it, less anything that already went back on it, never more than the reversed chargeback. With refund or credit the listener re-reads the follow-up at Mollie when it runs — and for credit the original too — and gives back only what is still paid twice then: a refund you made in the Mollie dashboard in the meantime is not given back a second time.

A refusal the retries cannot fix (a missing Mollie scope, a disconnected tenant, an amount Mollie no longer allows) flags the follow-up the same way. The retries need a real queue: on QUEUE_CONNECTION=sync the settlement runs inline inside the webhook, without retries, and a throw there drops the after-commit work queued behind it (unsupported — larsasub:doctor warns).

Where a follow-up shows

The dashboards. On the payments dashboard, on top while payable, as "due after chargeback" with the chargeback's date — its original beside it as "charged back". On the subscription dashboard only while it belongs to the customer's active subscription. The operator dashboard lists it under "owed after chargeback" (also one whose payment start is stuck), with the total owed.

The customer hears of it through the chargeback mail, which says whether it is open (with a pay button), settled from credit, paid or in progress — the follow-up itself is notify = false. The button is notifications.payment_url, else the bundled payments dashboard, which is left out while the follow-up's Connect tenant drains a requested disconnect.

When it cannot be paid although it is payable: when the owner was erased, the Connect account disconnected or draining a requested disconnect, or no single payment method is available for the tenant, the checkout fails (422) and the follow-up stays open.

Counting paid charges

The original keeps paid_on after its chargeback, and a paid follow-up is a second paid charge for the same period. Two questions, two answers:

Reminder series (dunning)

After a collection fails, the customer gets one mail. A reminder series follows up: on the days in dunning.reminders.steps, counted from the failure, the customer is reminded until the charge is paid — and a step with escalate tells your application, through an event, that the debt is escalated. The package never cuts off access or ends a subscription by itself. The series is off by default; see Switching it on.

'reminders' => [
    'enabled' => env('LARSASUB_DUNNING_REMINDERS_ENABLED', false),
    'since' => env('LARSASUB_DUNNING_REMINDERS_SINCE'),        // Y-m-d, required once enabled
    'steps' => [
        ['after_days' => 3, 'action' => 'notify'],
        ['after_days' => 10, 'action' => 'notify'],
        ['after_days' => 14, 'action' => 'escalate', 'notify' => false],
    ],
],

That is the shipped dunning.reminders block. after_days must increase strictly; action is notify (a reminder) or escalate (an event, with 'notify' => true also a last reminder). Any other key, or notify => false on a notify step, is a boot failure — even while the series is off. Each series keeps the schedule it opened with; a change here only affects new series.

Which charges get a series

Two kinds, each selected on its own:

RecurringCharge::query()->dunningCollectionFailed($since) and ->dunningChargeback($since) select the candidates, and $charge->dunningOrigin($since) asks the same for one charge ($since is a CarbonInterface, or null for no lower bound). Neither asks about the subscription: a candidate gets a series only when notPayableReason() is null as well, so a failed charge on an ended subscription is a candidate that never gets one.

Nothing older than dunning.reminders.since gets a series — a failed collection by its planned_on, a follow-up by its own created_at (it copies the original's planned_on, and a chargeback can arrive 13 months after the debit).

One series per charge, ever. A series is unique per charge: closing it ends reminders for that charge for good, and a later debt is another charge (each chargeback has its own follow-up), so it gets its own series.

What a run does

larsasub:process-dunning (daily; a no-op while dunning.reminders.enabled is off) opens a series for every charge that qualifies, then looks at every running one:

--dry-run prints what a run would do and writes and sends nothing; it ignores enabled, and takes since from the config or from --since (which only works together with --dry-run).

A payment ends a series straight away. When the charge is paid, its series is resolved as paid right after the payment commits, and DunningResolvedEvent goes out — also while the series is switched off, for a series that was running before. The run would derive the same on its next pass; this only saves the customer that day. A follow-up paid after its chargeback was reversed is the exception: the run closes it as chargeback_reversed.

The reminder mail

Every step that reminds (a notify step, or an escalate step with 'notify' => true) mails the customer, in the billable's language:

An escalation mails no one: what it means is your application's decision (DunningEscalatedEvent). larsasub:doctor says so when the schedule escalates and nothing listens.

Switching it on

  1. Copy the whole dunning.reminders block, and the two dunning_reminder keys under notifications.user / .company, into a config you published before 0.32. The package fills in only missing top-level keys, so without the block LARSASUB_DUNNING_REMINDERS_ENABLED silently does nothing. larsasub:doctor lists missing keys.
  2. Set LARSASUB_DUNNING_REMINDERS_SINCE to the first day whose debts should get a series (Y-m-d) — without it, switching on is a boot failure, because every old debt would be escalated on day one.
  3. Preview: php artisan larsasub:process-dunning --dry-run --since=2026-10-01 (it needs scheduling.enabled).
  4. Make sure the run runs: the scheduler, and — with scheduling.auto_register off — larsasub:process-dunning in your own schedule. A queue worker that is not sync delivers the mails.
  5. Check the pay button: with the payments dashboard off, or in explicit billable mode, set notifications.payment_url.
  6. Set LARSASUB_DUNNING_REMINDERS_ENABLED=true.

Watching it. While the series and the scheduler are on, larsasub:doctor counts running series nobody looked at for twice the run interval and at least two days (the run is not reaching them), and series paused for more than seven days, per reason. No series resumes on a timer: a long pause is for somebody to look at.

Reading and closing series

$owner->dunningCases();                         // every series of the owner, newest first
$billable->dunningCases()->open()->get();       // the ones still running (active or paused)
$billable->dunningCases()->unresolved()->get(); // …and the exhausted ones: not yet paid or closed
$charge->dunningCase;                           // the series of one charge, or null
$case->charge;                                  // and back
$case->steps;                                   // the steps it took

app(DunningService::class)->close($case);       // no further reminders; false if already resolved or no longer owed

close() does not change whether the charge can be paid: a charge the customer could pay stays payable, one paused for a payment under way or a flag is no more payable than before, and a payment is recorded as usual. It refuses a series whose charge is already paid, or whose chargeback was reversed: that series ends as what it is, not as closed by hand. A series is written only through DunningService — its status is derived from the charge under locks, and a direct update races the dunning command. Models\DunningCase and Models\DunningStep are yours to read; their columns are contract.

Stored values

Every backed value below is stored on the series and step rows and carried by the events, so your code may branch on it.

Status (DunningCase::$status, Enums\DunningStatus):

Value Meaning
active Steps run as scheduled
paused No step runs: a payment of the charge is under way, or the charge is flagged for an operator (pause_reason). Resumes by itself once the charge is payable again; never ends on a timer, because the payment it waits for may exist
resolved_paid The charge was paid. Final
resolved_closed Ended without payment, for the reason in closed_reason. Final
exhausted Every step was taken and the charge is still unpaid. Not final: becomes resolved_paid when it is paid, or resolved_closed when it is closed

isOpen() is active or paused; isResolved() is one of the two resolved_*.

Origin (origin, Enums\DunningOrigin):

Value Meaning
collection_failed A collection the provider reported failed or expired
chargeback A chargeback follow-up

Why it was closed (closed_reason, Enums\DunningClosedReason):

Value Meaning
charge_deleted The charge was deleted
chargeback_reversed The bank reversed the chargeback the follow-up answered: nothing is owed
superseded Another charge replaced this one
subscription_inactive The subscription ended (a failed charge is not payable after the end)
subscription_missing The subscription no longer exists
not_eligible The charge no longer qualifies at all (a guard rail)
manual Closed through DunningService::close()

Why it is paused (pause_reason, Enums\DunningPauseReason):

Value Meaning
payment_in_progress A payment of the charge was started, or its start never finished
needs_attention The charge is flagged for an operator

Steps (DunningStep): one row per step a run took up — its action (Enums\DunningAction) and its result (Enums\DunningStepResult).

Value Meaning
notify Remind the customer
escalate Tell your application the debt is escalated; with 'notify' => true also a last reminder
Value Meaning
executed Carried out
skipped Passed over: a later step was due in the same run, and only the latest one runs
dropped Carried out, but the case was resolved before its events went out — they are not sent late

Retention

Resolved series are kept by default. With retention.dunning_days set, larsasub:prune-dunning-cases deletes older ones with their steps and model log, and keeps — reported per reason (Enums\DunningPruneBlocker) — what it cannot prove is done:

Value Meaning
undelivered-event An event of the case (its start, a step or its resolution) has not gone out yet
charge-still-eligible The charge is still an unpaid debt (a case closed by hand, or on an ended subscription): deleting its case would let the series start over
already-gone A concurrent run removed or changed the case first
error An unexpected failure on this case; logged, and tried again on the next run

A running series (active or paused) also keeps larsasub:prune-billables --orphaned away from its registration (has-open-dunning-case).

Query scopes

Every scope below is chained on RecurringCharge::query() — see Status methods for why.

// Execution and payment state
RecurringCharge::query()->isCharged()->get();
RecurringCharge::query()->isNotCharged()->get();
RecurringCharge::query()->isPaid()->get();
RecurringCharge::query()->isNotPaid()->get();
RecurringCharge::query()->isLiveAttempt()->get();            // claimed, neither paid nor failed
RecurringCharge::query()->notOfDrainingTenant()->get();      // Connect: not of a tenant draining a disconnect

// Supersession
RecurringCharge::query()->notSuperseded()->get();
RecurringCharge::query()->superseded()->get();

// Chargeback follow-ups — a debt, not a planned charge
RecurringCharge::query()->chargebackFollowUp()->get();
RecurringCharge::query()->notChargebackFollowUp()->get();
RecurringCharge::query()->openChargebackFollowUp()->get();   // still owed: unpaid, chargeback not reversed

// Reminder series — the charges that qualify for one (see "Reminder series")
RecurringCharge::query()->dunningCollectionFailed($since)->get();
RecurringCharge::query()->dunningChargeback($since)->get();

// Planning dates
RecurringCharge::query()->plannedOnDatePassed()->get();      // planned_on today or earlier
RecurringCharge::query()->plannedOnDateNotPassed()->get();
RecurringCharge::query()->plannedBetween('2026-01-01', '2026-12-31')->get();   // both days inclusive

// What the charge is for
RecurringCharge::query()->isSubscriptionCharge()->get();
RecurringCharge::query()->isStandaloneCharge()->get();
RecurringCharge::query()->mandateSetup()->get();
RecurringCharge::query()->notMandateSetup()->get();
RecurringCharge::query()->belongsToBillableSubscription($subscription)->get();

// Notification
RecurringCharge::query()->requiresNotification()->get();  // notify = true
RecurringCharge::query()->notNotified()->get();           // notified_at is null
RecurringCharge::query()->notificationDue($days)->get();  // planned_on within X days, still in the future

// Attention and payability
RecurringCharge::query()->needsAttention()->get();
RecurringCharge::query()->noAttentionNeeded()->get();
RecurringCharge::query()->mandateRegistrationFailed()->get();  // flagged with MANDATE_REGISTRATION_FAILED_MARKER
RecurringCharge::query()->isNotPayable()->get();          // the payable COLUMN is false — not is_payable

// Provider status
RecurringCharge::query()->hasFailedProviderStatus()->get();   // failed, expired, canceled
RecurringCharge::query()->hasPendingProviderStatus()->get();  // open, pending

// Ownership and tenancy
RecurringCharge::query()->forBillable($billable)->get();
RecurringCharge::query()->forBillable($billable)->forTenantOrStandalone($tenant)->get();

forTenantOrStandalone($tenant) keeps the tenant's subscription charges and every charge without a subscription — a standalone charge carries no tenant of its own. On its own it returns every standalone charge in the table, so always combine it with forBillable(), as the bundled dashboards do.

forBillable() is the scope. belongsToBillable() and ownedByUser() are instance methods from Traits\HasBillableOwnership — they answer a question about one loaded record, they do not filter a query.