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

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

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:

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:

  1. 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.
  2. 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):

  1. A 303 to the checkout the running verification already opened.
  2. 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.
  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(), no MandateMarkedUsableEvent, 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

Started and Parked both 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 Failed leaves a Billable registration behind — its restrict FKs make it undeletable from the host. That is expected: remove it later with larsasub: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_id is 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 as Released.

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.