Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Payment methods and mandates
Which payment methods the package supports, which one is used at each step of billing, how you switch them on (per tenant in Connect mode), and how mandates — the customer's standing authorization to collect — are set up, checked and removed.
- Supported methods
- When a payment is charged back
- Recurring vs single
- Enabling methods
- Tenant availability
- Mandates
- Setting up a mandate
- Mandate setup status
- Validating mandates
- Removing a mandate
Supported methods
Ten Mollie methods are seeded by larsasub:install, each with a recurring and
a single flag. All are seeded inactive — activate the ones your Mollie
account actually supports. Re-running larsasub:install adds methods a newer
version introduces and leaves the flags you set alone.
| Method | provider_id |
Recurring | Single | Notes |
|---|---|---|---|---|
| SEPA Direct Debit | directdebit |
✅ | — | Mandate established through a first payment in another method; first_payment_method_id points at iDEAL by default |
| Credit Card | creditcard |
✅ | ✅ | Establishes its own mandate |
| PayPal | paypal |
✅ | ✅ | Establishes its own mandate; works worldwide |
| iDEAL | ideal |
— | ✅ | NL; the default mandate opener for direct debit |
| Bancontact | bancontact |
— | ✅ | BE; can be used as the mandate opener instead of iDEAL |
| Apple Pay | applepay |
— | ✅ | |
| KBC/CBC | kbc |
— | ✅ | BE |
| Belfius | belfius |
— | ✅ | BE |
| EPS | eps |
— | ✅ | AT |
| TWINT | twint |
— | ✅ | CH |
To collect SEPA mandates from Belgian customers through a first Bancontact
payment, point the direct-debit method's first_payment_method_id at the
Bancontact method.
When a payment is charged back
What a chargeback means differs per method, and so does what the package does with it (chargeback follow-ups):
| Method | What the chargeback is | Follow-up by default |
|---|---|---|
directdebit (SEPA) |
Final: the customer has the money back, the debt for a delivered period stays real | Yes |
creditcard |
A dispute the merchant may still win; often about whether anything is owed at all | No — add it to dunning.chargeback_follow_up_methods to opt in |
paypal |
Handled between PayPal and the customer, outside Mollie; the package does not see it | No |
| iDEAL, Bancontact and other bank-transfer one-time methods | No chargebacks | — |
| Apple Pay | Runs on the card: a dispute like creditcard. Only the first payment reports applepay; the mandate it leaves is a card mandate, so a renewal reports creditcard |
Only if you list applepay (first payments) and creditcard (renewals) |
Recurring vs single
The two flags decide which step of billing a method can carry.
Recurring methods carry the automatic collection: they establish a mandate,
and larsasub:execute-charges collects against it without the customer being
present.
Single methods cover one-time payments and the manual recovery payment of a failed charge. Manual payment is recovery only — see Recurring charges.
| Step | Methods |
|---|---|
| First payment of a subscription | A recurring method. SEPA Direct Debit pays it in its opener (iDEAL by default) |
| Automatic renewals and cost-line charges | The mandate's method: SEPA Direct Debit, credit card or PayPal |
| The €0,01 mandate verification ("add a payment method") | A recurring method; SEPA Direct Debit verifies in its opener |
One-time payments (SinglePayment) |
A single method: iDEAL, Bancontact, Apple Pay, KBC/CBC, Belfius, EPS, TWINT, credit card or PayPal |
| Recovery of a failed charge | A single method — a failed SEPA debit is paid again by iDEAL, card or another single method, never by debiting again |
For a SEPA first payment paid by iDEAL, the package records the mandate Mollie created, adopts an existing mandate on the same bank account, or creates one from the IBAN the payment came from — in that order.
A one-time payment and the recovery of a failed charge are paid with the method
the customer picks when the payment starts, not the one the record was created
with: createSinglePayment() and startManualChargePayment() write the chosen
method onto the SinglePayment or the charge (payment_method_id) before the
checkout opens. The method must be active and a single method, and in Connect
mode available for the tenant.
Enabling methods
Methods are rows in larsasub_payment_methods (Models\PaymentMethod). Switch
one on by setting active — in the Nova or Filament companion, or in code.
| Property | Description |
|---|---|
name, description |
Shown to the customer |
internal_note |
For your operators only |
provider |
Provider code, e.g. mollie |
provider_id |
Provider-specific identifier |
active |
Whether the method is offered for new payments and mandates. Mandates that already exist keep collecting when you switch it off |
recurring, single |
Which flows it supports |
first_payment_method_id |
The method used to open a mandate for this one (credit card and PayPal point at themselves) |
tenant_usable |
Whether tenants may enable it (Connect mode); seeded true |
use PetersDevelopment\LarsaSub\Models\PaymentMethod;
PaymentMethod::query()->isEnabled()->get();
PaymentMethod::query()->isDisabled()->get();
PaymentMethod::query()->isRecurring()->get();
PaymentMethod::query()->isSingle()->get();
$method->firstPaymentMethod; // the mandate opener
$method->valid_for_provider; // matches the configured provider
Tenant availability
In Connect mode a tenant enables methods for their own Mollie account on top of
what the platform allows. Models\TenantPaymentMethod
(larsasub_tenant_payment_methods) holds those per-tenant switches, and the
tenant connect dashboard toggles them.
PaymentMethod::query()->isTenantUsable()->get(); // the platform allows tenants to use it
PaymentMethod::query()->availableForTenant($tenant)->get(); // active, tenant-usable, and enabled by this tenant
$method->isAvailableForTenant($tenant); // bool — the authoritative check
$method->isEnabledForTenant($tenant); // the tenant's own switch only
$method->tenantPaymentMethods;
Every tenant-scoped picker applies the predicate availableForTenant()
defines, and every server-side re-check asks isAvailableForTenant(). A method the
tenant has not enabled never shows up in the picker and is refused if it is
posted anyway.
The dashboard's switch (larsasub.tenant-connect.toggle-payment-method)
refuses to enable a method when:
- the platform has it inactive or not
tenant_usable(422); - its mandate opener is not enabled for the tenant yet — switch on iDEAL
before SEPA Direct Debit (422,
dependency_required); - the method is not active on the tenant's Mollie website profile (422,
not_available_in_provider), or Mollie could not be asked (503,provider_check_failed).
It also refuses to switch off an opener that another enabled method depends on
(422, has_dependents).
A headless host or an admin panel switches methods through
Service\TenantPaymentMethodService, which refuses the same cases with a
translated LarsaSubException:
use PetersDevelopment\LarsaSub\Service\TenantPaymentMethodService;
$methods = app(TenantPaymentMethodService::class);
$methods->methodsForTenant($tenant); // active, tenant-usable methods, each with enabled_for_tenant
$methods->availableSingleMethods($tenant); // the one-time methods the tenant's customers can pay with now
$methods->toggle($tenant, $paymentMethod); // switches it; returns the new enabled state
Mandates
A mandate is the customer's standing authorization to collect. LarsaSub stores it
as a ProviderMandateModel — MollieMandate for the Mollie provider — scoped by
tenant in Connect mode.
$owner->providerMandates($tenant); // all mandates (optional tenant filter)
$owner->usableMandates($tenant); // active, usable mandates
$owner->hasUsableMandate(); // any tenant
$owner->countUsableMandates(); // any tenant
$mandate->isUsable();
Pass the tenant in Connect mode. Without it these helpers look across every tenant the owner has a mandate with, so a mandate at tenant A answers for tenant B.
Mandates are billable-scoped, not subscription-scoped. Cancelling or terminating a subscription never touches the mandate — see Removing a mandate.
To move a running subscription onto a different mandate:
$billableSubscription->switchMandate($mandate);
It throws a LarsaSubException unless the subscription is active or upcoming
and the mandate belongs to the same billable and is usable. The subscription
takes the mandate's payment method, and its planned charges move with it.
Setting up a mandate
There are two ways a mandate comes into existence:
- As a side effect of the first subscription payment. The customer pays the first charge with a mandate-opening method (iDEAL for direct debit), and the provider returns a mandate along with the payment.
- Through a standalone mandate-setup payment — a €0,01 verification charge whose only purpose is to establish the mandate. This is the "add a payment method" flow in the bundled payment-methods dashboard.
use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;
$provider = PaymentProviderFactory::make('mollie');
$provider->createMandateSetupPayment($owner, $paymentMethod); // RedirectResponse to the checkout
$paymentMethod must be a recurring method; for SEPA Direct Debit the
checkout runs in its opener. For a tenant whose Mollie disconnect is draining it
throws ConnectDisconnectPendingException before anything is written.
The €0,01 charge is a standalone RecurringCharge with the
larsasub.reference.mandate_setup_prefix reference prefix. It is deliberately
excluded from three places: it is never invoiced, never listed in
outstandingCharges(), and never manually payable.
Calling it twice
Calling it again within the hour never collects twice. The guard and the charge are one atomic claim, so a repeat submit gets one of three answers instead of a second €0,01 — and the third of them holds only while the provider still remembers the key (see the bound below):
- A 303 to the checkout the running verification already opened.
- A
MandateSetupInProgressException(larsasub::method.setup-already-started) while another request took the claim moments ago and no checkout URL exists yet. Treat it as "reload", not as a failure; the bundled dashboard answers it with HTTP 409 (a JSON request) or a flashed error (a browser request) and shows the message. The claim is committed before the provider call, so that other request may be at the provider, on its way there, or already dead: the checkout appears if it gets far enough to store one, and otherwise the 60-second window runs out and the next attempt takes answer 3. - A retry of the same claim — same charge row, therefore the same idempotency key. Mollie answers that key either with the payment the first attempt made, or, if that attempt never reached it, by creating the payment now; both leave exactly one payment. The whole request is rebuilt from the claim, because a different method would create a payment for an instrument the claim does not describe.
On answers 1 and 3 the $paymentMethod you pass is therefore ignored (it is
written to the charge's log): asking for a card while a direct-debit
verification is open lands on the direct-debit checkout.
The bound is a condition on YOUR schedule, not a property of the package. Mollie keeps idempotency keys for one hour, so answer 3 stops deduplicating past that: a retry on a claim older than an hour does create a second €0,01, on that same charge row. Answers 1 and 2 are unaffected — they never reach the provider.
What closes that gap is reconciliation resolving the claim inside the hour,
and the shipped defaults do not do that. execute_charges_frequency is
daily, so a claim stranded just after a run waits nearly a day. The worst
case is reconcile_unresolved_after_minutes (default 30) plus your scheduling
interval, and that sum has to stay under 60 — which daily, or even hourly,
does not.
If customers can reach "add a payment method" unattended and you want answer 3
to hold, schedule execute-charges at everyFifteenMinutes (30 + 15 = 45) or
finer, or lower the threshold to match. Otherwise treat answer 3 as covering
the double-click and the quick reload — which is what it is for — and not the
customer who comes back tomorrow.
When nothing seems to happen
Adding a method the customer already has is a silent no-op. If the verification resolves to a mandate that is already recorded and usable — paying again from the same IBAN, the same card — the package returns the existing record rather than creating a second one, and everything hanging off "a mandate became usable" is skipped with it: no
markUsable(), noMandateMarkedUsableEvent, no "payment method added" mail. The customer paid their cent and sees nothing happen. If your flow lets customers re-add a method they already have, confirm it yourself rather than waiting for a package event that will not come.
A recorded mandate that had been marked unusable or removed is revived instead,
and that does fire MandateMarkedUsableEvent and the mail.
A failed registration behind a paid verification is a different case: the
cent was collected and there is still no payment method. That one is reported
— see mandate_registration_failed
and MandateRegistrationFailedEvent — and the recovery pass in
larsasub:execute-charges repairs it.
Mandate setup status
A mandate-first onboarding needs an answer before the customer has returned from
the provider. mandateSetupStatus() gives one:
$status = $owner->mandateSetupStatus($tenant); // Enums\MandateSetupStatus
$charge = $owner->latestMandateSetupCharge(); // the underlying €0,01 charge, or null
$status->mayStillComplete(); // Started | Parked — do not clean up, do not re-offer
$status->isTerminal(); // the negation — nothing left to wait for
Use the two helpers rather than classifying the cases yourself. The tempting
shortcut — narrowing Started with "does it have a provider payment id?" —
throws away exactly the case the protection exists for (see the third warning
below).
One caution on isTerminal(): it is also true for None, which means no
attempt exists — a user who has not started yet, not an outcome. Gate
destructive teardown on Failed/Released specifically.
MandateSetupStatus |
Meaning | What to do |
|---|---|---|
None |
No attempt on record | Offer a setup |
Started |
Checkout in flight, normal wait | Wait. Poll or reload |
Parked |
In flight, and the reconciliation's own provider lookup failed long enough that an operator was alerted | Wait. The €0,01 may well exist at the provider |
Released |
The provider lookup proved no payment exists; the leftover attempt was removed | A new setup is safe |
Failed |
The checkout ended terminally at the provider | Safe to roll back; offer a retry |
Confirmed |
A usable mandate confirms a non-failed attempt | Done |
StartedandParkedboth mean the outcome is still in flight — do not clean the attempt up, and do not offer a second setup checkout. Providers retry webhooks for hours, and rolling back an in-flight attempt destroys a paid onboarding: the late webhook then creates a usable mandate on an orphaned registration.
Rolling back after
Failedleaves a Billable registration behind — its restrict FKs make it undeletable from the host. That is expected: remove it later withlarsasub:prune-billables --orphaned(see Commands), which waits out a quiet window and re-proves every guard before deleting anything.
A setup charge without a
provider_payment_idis not evidence that nothing was created. The charge is written before the provider call, precisely so a process that dies in between leaves something reconciliation can find. Only the provider lookup decides, and its verdict shows up asReleased.
How the status is decided
Confirmed requires a usable mandate that confirms a non-failed attempt, created
on or after it began. A failed latest attempt always reports Failed, so
mandates that predate it or arrived from a parallel flow do not confirm it.
Without any attempt on record, any usable mandate is Confirmed.
latestMandateSetupCharge() deliberately returns only a live charge — a
removed attempt is nothing a host can act on. When the newest attempt was
removed (status Released, or None), it returns the latest attempt still
live — an earlier one, which the status does not describe — or null. It is
also null for a Confirmed that rests on a mandate from another flow with no
live attempt behind it.
How long "unknown" lasts
The provider lookup that turns an id-less attempt into Released or Parked is
PaymentReconciliationService::reconcileUnresolvedCharges(). It runs at the
start of every larsasub:execute-charges run (default daily,
LARSASUB_EXECUTE_CHARGES_FREQUENCY), picking up claims older than
scheduling.reconcile_unresolved_after_minutes (default 30). So with defaults
an unknown outcome resolves within a day.
That has nothing to do with the weekly larsasub:validate-mandates, which
re-checks existing usable mandates. A host that cannot wait for the next
scheduled run calls reconcileUnresolvedCharges() directly (it is public,
idempotent, and scoped to unresolved claims), or schedules execute-charges
more often.
The terminal outcome also fires MandateSetupFailedEvent, which replaces the
generic charge failed/expired/canceled events for setup charges, so an
abandoned verification checkout does not mail the company.
Validating mandates
larsasub:validate-mandates re-checks usable mandates against the provider
(weekly by default, scheduling.validate_mandates_frequency) and marks revoked,
invalid and vanished ones unusable, dispatching MandateMarkedUnusableEvent.
Each check also refreshes the stored status and details
(refreshMandateStatus()). Without it, a customer who cancels a direct debit at
their bank is only discovered when a collection fails.
The run is bounded. --limit or scheduling.validate_mandates_limit caps it,
and a resume cursor (last_validated_at) makes the next run continue where
this one stopped, so a mandate fleet larger than one run's budget is still
fully covered over successive runs. There is deliberately no unbounded mode:
an unset or invalid limit falls through to a built-in default of 5000, sized
against the scheduler mutex. scheduling.validate_mandates_delay_ms (default
100) spaces the provider calls.
Removing a mandate
Always go through Service\MandateService::removeMandate(), never the raw
provider call.
use PetersDevelopment\LarsaSub\Service\MandateService;
$mandates = app(MandateService::class);
$mandates->removeMandate($owner, $mandateId, $tenant);
It refuses when removal would break collection, throwing a
MandateServiceException whose reason is an Enums\MandateRemovalBlocker —
switch on that, never on the translated message:
MandateRemovalBlocker |
Meaning |
|---|---|
NotFound |
No such mandate for this owner |
NotOwned |
The mandate belongs to a different billable |
InFlightCharges |
Charges are already at the provider and unpaid — they cannot be reassigned |
LastUsableMandate |
Removing it would leave the subscriber with no way to be collected |
NoCompatibleReplacement |
No other usable mandate can take over the running subscriptions |
Ask first, so the UI can explain instead of throwing:
$blockers = $mandates->removalBlockers($owner, $tenant); // [mandate id => MandateRemovalBlocker|null]
null means removable. It is a prediction: removeMandate() re-checks under
a row lock, so a removal can still fail if a charge goes in flight in between.
Pass the tenant in Connect mode, or mandates at the owner's other tenants count
as replacements.
When removal is allowed, removeMandate() reassigns every active and upcoming
subscription, their planned charges and any standalone planned charge on the
mandate to a replacement mandate (preferring the same payment method, then any
usable one), revokes at the provider, removes the mandate locally (a soft
delete), and then dispatches MandateRemovedEvent with the mandate and its
replacement. A revocation that fails at the provider does not throw: it is
logged, and the mandate is marked unusable and removed locally anyway.
Offboarding: cancel first, remove second. A host offboarding a customer revokes here — but only after the subscriptions have ended and their charges settled. While anything still depends on the last usable mandate, removal refuses with
LastUsableMandate. Cancellation and termination deliberately leave the mandate alone: it is billable-scoped, and the customer may still owe an outstanding charge or start a new subscription.