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
- Properties
- Status methods and transitions
- Charges you create yourself
- Supersession
- Manual payment: the recovery path
- Outstanding charges and why a charge is not payable
- Refunds and chargebacks
- Chargeback follow-ups
- Reminder series (dunning)
- Query scopes
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:
- Bind every pay button to
is_payable, and list them withpayableCharges(). Show what is still owed withoutstandingCharges(). - Listen to the charge events for anything your application does on a payment, a failure or a chargeback.
- Keep the scheduler running:
larsasub:plan-charges,larsasub:execute-chargesandlarsasub:notify-charges— andlarsasub:process-dunningonce the reminder series is on. See Scheduling.
What you never do — each of these costs money:
- Never offer a payment on a charge that is not
is_payable, and never build that list from the rawpayablecolumn. It surfaces planned charges the scheduler will still collect, and a pay button there collects twice. - Never delete or anonymise a subscription, mandate or billable while one of
its charges
isLiveAttempt(): the paid webhook then has nothing to book the money against. - Never sum money over superseded charges. Filter on
notSuperseded(). - Never collect a chargeback follow-up from the mandate. No pre-notification
went out for it; the provider's
executeCharge()refuses it. - Never write a reminder series yourself.
DunningServiceis the only writer. - Never call
startManualChargePayment()orRefundService::refund()inside a transaction of your own. Both call Mollie, and your transaction would hold the package's locks across that call.
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 onBillableSubscription,Subscription,PaymentMethodandCreditTransaction). PHP resolvesRecurringCharge::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 asperiod.
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);
flagFailedCollection()is what you call when your own code catches a failedexecuteCharge(). It addsCREATED_NOTHING_MARKERonly when the failure provably created nothing at the provider — and that marker is what keeps the charge payable by hand.setNeedsAttention()there would leave it unpayable.clearNeedsAttention($note, $requireMarker)is one conditional update: it lifts the flag only while the message still opens with$requireMarker(passnullto clear whatever stands), logs your note, and returnsfalsewhen someone else changed the flag first.clearNeedsAttentionByKey()takes a model-log key instead of free text;ProviderPaymentModel::ATTENTION_CLEARED_BY_ADMIN_KEYis there for an admin screen.- Clearing the flag on a charge nobody claimed hands it back to the
scheduler: the next
larsasub:execute-chargesrun collects it.
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
onPaid()clearspayable, generates the invoice (with invoicing on), dispatchesRecurringChargePaidEventand resolves the charge's reminder series. It carries the idempotency guard for activation, under a row lock — the same webhook arriving twice cannot activate twice.onFailed(),onExpired(),onCanceled()make an ordinary charge payable again and dispatch their event. A failed first payment instead deactivates the subscription and stays unpayable: it is replaced by a fresh charge (retryFailedFirstPayment()), never reoffered. A mandate verification takes its own path and dispatchesMandateSetupFailedEvent.settleLocally($reasonKey)settles a charge without a provider payment — what the package does for a zero-amount or fully credit-covered charge, at planning or when it reacheslarsasub:execute-charges. It routes throughonPaid(), so the charge is invoiced as collected: zero its amount first when nothing was collected.
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:
- after a failed, expired or canceled provider payment;
- after the scheduler flagged the charge when nothing was created at the
provider — a failed execution attempt that provably created nothing
(
needs_attentionopening withRecurringCharge::CREATED_NOTHING_MARKER, for example no usable mandate), or a charge flagged before it was claimed; - for a chargeback follow-up, which exists to be paid.
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:
- No
payable = 1filter. That column defaults to false and is webhook/claim bookkeeping, not "money is owed". Filtering on it would hide exactly the interesting rows — a manual payment in flight and a failed first payment both carrypayable = false. - Superseded and mandate-setup charges stay excluded. A replaced charge is not outstanding (its successor is, and listing both double-counts the amount), and the €0.01 verification is not a delivery and never a debt.
- Unattempted charges stay excluded unless they are flagged. They are future
billing, not arrears, so
NotAttemptedYetnever appears in this list. The unattempted charges that do appear are a chargeback follow-up, which is payable, and a charge flagged before it was claimed.
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:
- What it asks. The chargeback's amount — what the bank took back — capped so several chargebacks together never ask more than was collected. A refund you made earlier does not lower it: the bank returned the full debit anyway. Credit in the customer's wallet is applied at once when the follow-up is in the wallet's currency; a follow-up fully covered by credit is settled on the spot.
- For which period. The original's:
planned_on,descriptionand the VAT snapshot are copied, and so is the breakdown when the chargeback took the whole charge and no credit was applied to it. Its invoice, once paid, repeats the original's split. - How it is paid. Only by the customer, through the
manual payment flow — it is never
collected from the mandate, because no pre-notification went out: the
scheduler skips it, and the provider's
executeCharge()refuses it whoever calls it. It is inpayableCharges()andoutstandingCharges()from the moment it exists, and it stays payable when the subscription has ended. Headless: start the payment yourself. - What leaves it alone. The executor, plan changes, cancellation, a mandate
switch, the introduction and period counters, and the tenant dashboard's
planned revenue all skip it (
RecurringCharge::notChargebackFollowUp()). - What waits for it. A plan change is refused while a follow-up of the
subscription is unpaid (
BillableSubscriptionServiceException,change-open-chargeback-follow-up): the change credits the undelivered rest of the period from what was retained, and a follow-up paid after the change would pay that rest a second time. - What it is not. The original is not superseded — it stays paid, with its own invoice and the credit note the chargeback produced. A follow-up never gets a follow-up of its own.
- Connect. No application fee is taken on the follow-up unless
dunning.chargeback_follow_up_application_feeis on: Mollie leaves the original's fee on the platform's balance, so a second fee charges the tenant twice for one period.
$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 bylarsasub: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:
larsasub:execute-chargesfetches the original payment of every follow-up younger thandunning.chargeback_reversal_check_days(default 60;0switches this look off) whose chargeback is not yet seen reversed — paid ones included, so a double payment is found. On the shipped schedule it runs beforelarsasub:process-dunning(same daily tick, registered first), so the reminders of that day no longer go out for a debt that is gone; a schedule of your own should keep that order.- Starting a payment on a follow-up (
startManualChargePayment()) asks Mollie first. A reversal found there withdraws the follow-up and the start is refused as not payable; when Mollie cannot be reached, the start goes ahead. It calls Mollie before it locks anything: do not wrap it in a transaction of your own.
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.
signal(default) — nothing moves. The follow-up is flaggedneeds_attention(RecurringCharge::FOLLOW_UP_DOUBLE_PAID_MARKER), which mails the company (whilenotifications.company.payment_needs_attentionis on; a headless host listens for the event), and you give it back. Clearing the flag returns nothing. Mollie most likely refuses a refund on the original (seerefund). A refund on the follow-up's own payment normally goes through, but with invoicing on it also credits the follow-up's invoice: the original's was already credited by the chargeback and nothing re-invoices it after the reversal, so the period's invoices then net to zero while the money stayed with you — correct that in your bookkeeping (the package has no debit note). Wallet credit on the original (CreditService::returnCollectedAsCredit(), see Refunds) leaves the follow-up's invoice standing — whatcreditdoes by itself.refund— a queued listener refunds it at Mollie, with retries and an idempotency key bound to the follow-up. Normally on the original payment: its invoice was already voided by the chargeback's credit note, so the refund needs no new document, and the customer keeps the follow-up's invoice. When the original cannot take it — an earlier refund on it leaves too little, or it is gone, unpaid or has no provider payment — the package refunds the follow-up instead (and returns its applied credit), which voids the follow-up's invoice too. Which of the two is decided when the double payment is found and carried in the event ($settleOn), so a retry never picks the other. Mollie very likely refuses the refund on the original: once a payment was charged back it most probably cannot be refunded again, even after the reversal (itsamountRemainingstays 0 — not confirmed by Mollie). The follow-up is then flagged as withsignal. That is whysignalis the default.credit— the same amount as wallet credit instead. The refund ceiling counts that credit, so the same money cannot also be refunded later. It still reads Mollie first: when the provider cannot be reached, or money already went back by hand, the follow-up is flagged instead of credited.
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:
- Cash received — sum
$charge->receivedAmount():amountminus the part a standing chargeback took, minus completed refunds. Do not drop charges with a standing chargeback instead: a €10 chargeback on a €30 charge leaves €20 received, and after a reversed chargeback whose double payment was refunded on the original, the original counts in full again while the money went back — both come out wrong. Null means an entry could not be read. Money given back as credit (double_paid = credit) is not subtracted: it stays with you as a wallet balance and lowers the charge it is later spent on — for invoiced revenue in that month, use the invoices and credit notes. - Paid periods — leave out a charge whose chargeback still stands:
$charge->hasStandingChargeback().paidThrough()does exactly that.
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:
- A failed collection — a charge whose provider payment ended
failedorexpiredand that the customer can pay again (notPayableReason()is null).canceledcounts only on a checkout the customer started — a manual retry, which carries aprovider_url— because there the customer clicked cancel. On a collection through the mandatecanceledis in practice an operator's act at the provider, and it does not count. Standalone charges count. A charge flaggedneeds_attentionnever does: the executor flags a timeout whose SEPA collection may still be running. - A chargeback follow-up that is still owed and payable.
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:
- Its status follows the charge. A paid charge resolves the series as paid. A deleted or superseded charge, a reversed chargeback, or an ended or missing subscription closes it. A charge flagged for an operator, or with a payment under way, pauses it; it resumes by itself once the charge is payable again — never on a timer, because the payment it waits for may exist.
- Steps count in days from the failure (a follow-up: from its chargeback mail) — or from the day the series opened, when it opened after its first step was already due (an old debt when you switch the series on, a charge that was in flight or flagged). No step goes out within a day of the opening or a resume, of the customer's last payment attempt (its own failed or canceled mail went out), or of the previous step.
- Catching up. When several steps fell due at once (the scheduler was
down, the series was paused), only the latest is taken and the earlier
ones are recorded as
skipped. While no reminder was taken yet, a due escalation waits for the latest due reminder to go first, and follows a day later at the earliest; a schedule without a reminder escalates directly. Nor is the schedule's LAST reminder passed over for a later step that does not remind: it goes first, and that step follows a day later. After the last step the series isexhausted. - Events go out when a series opens, takes a step or resolves (a pause or a resume fires none), at least once — see Dunning events. A step whose events did not go out holds the series until they do; if the charge was paid or no longer owed by then, they are dropped instead.
--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:
- What it says depends on the debt. A failed collection names the plan and the period, or — for a standalone charge — its description. A chargeback follow-up says when the bank took the payment back and why, with any credit already applied. The amount open is always there, and the last reminder of the schedule says so in its subject. With several billables per user (explicit mode) it names the billable.
- The pay button follows the payment-failed mail:
notifications.payment_url(an invokable gets the charge — for a follow-up, the debt itself; for a standalone charge one without a subscription), then the bundled payments dashboard, then none. The dashboard button is left out while the charge's Connect tenant drains a requested disconnect, where the start is refused; apayment_urlof your own is left alone. Without a button the text promises none. - It is decided when it renders. A reminder that waited in the queue does not go out once the charge is paid, a payment is under way, the charge is flagged or no longer owed, the series was closed, or a later step was already taken. A reminder held back like that is not sent later: a pause usually means the customer is paying.
- Toggles.
notifications.user.dunning_reminder(on); the company copynotifications.company.dunning_reminder(off — the operator already got the failed or chargeback mail; it states the debt and the step reached, not whether the customer was mailed). To remind customers yourself, switch the user key off — or list\PetersDevelopment\LarsaSub\Listeners\Dunning\SendDunningReminderToUserListener::classinnotifications.disabled_listeners— and listen toDunningReminderDueEvent. The boot check andlarsasub:doctorthen keep warning that no customer reminder goes out; that warning is the price of catching the same switch flipped by accident.
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
- Copy the whole
dunning.remindersblock, and the twodunning_reminderkeys undernotifications.user/.company, into a config you published before 0.32. The package fills in only missing top-level keys, so without the blockLARSASUB_DUNNING_REMINDERS_ENABLEDsilently does nothing.larsasub:doctorlists missing keys. - Set
LARSASUB_DUNNING_REMINDERS_SINCEto 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. - Preview:
php artisan larsasub:process-dunning --dry-run --since=2026-10-01(it needsscheduling.enabled). - Make sure the run runs: the scheduler, and — with
scheduling.auto_registeroff —larsasub:process-dunningin your own schedule. A queue worker that is notsyncdelivers the mails. - Check the pay button: with the payments dashboard off, or in explicit
billable mode, set
notifications.payment_url. - 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 withforBillable(), as the bundled dashboards do.
forBillable()is the scope.belongsToBillable()andownedByUser()are instance methods fromTraits\HasBillableOwnership— they answer a question about one loaded record, they do not filter a query.