Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Mollie Connect (multi-tenant)
With Mollie Connect, each tenant of your platform has their own Mollie account: their customers' payments settle directly to the tenant, and the platform can take an application fee on each one. LarsaSub handles the OAuth onboarding, per-tenant API clients, per-tenant plans, fees, payouts and notification routing. Read this page when you are building a platform that bills on behalf of others; a single-merchant app stays in simple mode and needs none of it.
Connect requires the Platform tier. It settles payments to Mollie accounts that are not yours, which is what that tier covers. Declare it with
LARSASUB_LICENSE_TIER=platformbefore enabling Connect; on any other tier the package refuses to boot rather than bill quietly on a license that does not cover it. The declaration is a local config value — not verified, not signed, never sent anywhere. Runphp artisan larsasub:licenseto see what an installation declares.
The page is ordered the way you build it: set it up, onboard tenants, take fees, run it. Reference material follows.
- Billing modes
- Configuration
- The OAuth flow
- Choosing the website profile
- Onboarding status
- Application fees
- Dual billing
- Disconnecting a tenant
- Losing a connection
- The organization-change guard
- Settlements
- MollieConnectService
- Tenant-aware models
- Admin dashboard authorization
- Notification routing
Billing modes
LarsaSub runs in one of three modes. Connect and dual billing are the platform modes.
| Mode | Config | Description |
|---|---|---|
| Simple (default) | connect.enabled = false |
One global Mollie API key; all payments land in one account |
| Connect | connect.enabled = true |
Each tenant has their own Mollie account via OAuth |
| Dual billing | Connect + billable.platform_model |
The platform bills tenants with its own key and tenants bill their users through Connect |
Configuration
Setting Connect up takes five steps: declare the license, create a Mollie
Connect app, fill in the config block, tell LarsaSub how a billable finds its
tenant, and keep the default scopes. Then run php artisan larsasub:doctor.
If your billable owners live in per-tenant databases, also configure the
connect.tenant_context hook — see
Multi-database tenancy.
The license
LARSASUB_LICENSE_TIER=platform
With connect.enabled on and any other tier, every web request and every
console command fails at boot with a LicenseTierException. A short list of
commands that cannot move money still runs, so the configuration can be
repaired: config:clear, config:cache, cache:clear, optimize:clear,
package:discover, vendor:publish, list, help, larsasub:install,
larsasub:license and larsasub:doctor. schedule:run, migrate and
optimize are refused on purpose.
The Mollie Connect app
Create an OAuth app in your Mollie dashboard and register its redirect URI:
https://your-app.example/api/larsasub/connect/mollie/callback
That is the larsasub.mollie.connect.callback route, built from
config('app.url') — or from LARSASUB_NGROK_DOMAIN in a local or
development environment. Then put the credentials in your environment:
LARSASUB_CONNECT_ENABLED=true
MOLLIE_CONNECT_CLIENT_ID=app_xxxxxxxx
MOLLIE_CONNECT_CLIENT_SECRET=xxxxxxxx
MOLLIE_ORGANIZATION_ID=org_xxxxxxx
MOLLIE_ORGANIZATION_ID is your own Mollie organization. A client id that does
not start with app_ is a boot error; a missing id or secret is a boot warning,
because no tenant can connect and token refresh fails.
The config block
The connect block of config/larsasub.php, with its defaults:
'license' => [
'tier' => env('LARSASUB_LICENSE_TIER', 'single'),
],
'connect' => [
'enabled' => env('LARSASUB_CONNECT_ENABLED', false),
'tenant_relation' => env('LARSASUB_CONNECT_TENANT_RELATION'), // relation on the billable owner → tenant
'tenant_context' => null, // per-tenant databases only
'client_id' => env('MOLLIE_CONNECT_CLIENT_ID'),
'client_secret' => env('MOLLIE_CONNECT_CLIENT_SECRET'),
'scopes' => [
'payments.read', 'payments.write',
'refunds.read', 'refunds.write',
'customers.read', 'customers.write',
'mandates.read', 'mandates.write',
'profiles.read', 'organizations.read',
'onboarding.read', 'settlements.read',
],
'platform_organization_id' => env('MOLLIE_ORGANIZATION_ID'),
'fee' => [
'enabled' => env('LARSASUB_CONNECT_FEE_ENABLED', false),
'type' => env('LARSASUB_CONNECT_FEE_TYPE', 'percentage'), // or 'fixed'
'value' => env('LARSASUB_CONNECT_FEE_VALUE', 10), // 10% or €10.00
'description' => env('LARSASUB_CONNECT_FEE_DESCRIPTION', 'Platform fee'),
],
'tenant_label' => env('LARSASUB_CONNECT_TENANT_LABEL', 'organization'), // UI label
'tenant_name_attribute' => env('LARSASUB_CONNECT_TENANT_NAME_ATTRIBUTE', 'name'), // display name in mails
'tenant_owner_attribute' => env('LARSASUB_CONNECT_TENANT_OWNER_ATTRIBUTE', 'owner_id'), // who may manage the connection
'authorize_route' => env('LARSASUB_CONNECT_AUTHORIZE_ROUTE', 'larsasub.mollie.connect.authorize'),
'notifications' => [
'also_notify_platform' => env('LARSASUB_CONNECT_ALSO_NOTIFY_PLATFORM', false),
],
'disconnect_drain_days' => env('LARSASUB_CONNECT_DISCONNECT_DRAIN_DAYS', 30),
],
connect.tenant_owner_attributemust name a real column on your tenant model. The ownership check silently depends on it: a wrong name means the owner lookup finds nothing and nobody can manage the tenant. Nothing checks it at boot — verify it yourself when you wire up Connect.
Resolving the tenant
connect.tenant_relation names the method on your billable owner model that
returns its tenant. With Connect enabled and no relation set, the package
refuses to boot: no billable could find its tenant.
LARSASUB_CONNECT_TENANT_RELATION=organization
The package checks the name with method_exists() and then reads it as a
property, so an ordinary Eloquent relation works as it is:
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
When the tenant comes from request state rather than a database column (the
owner has no organization_id), implement the relation as both a method
and a classic accessor. The method satisfies method_exists(); the accessor
answers the property read.
The names must match connect.tenant_relation exactly. With
'tenant_relation' => 'organization', that is organization() and
getOrganizationAttribute() — a mismatch makes method_exists() fail and the
tenant silently never resolves:
public function organization(): ?Tenant
{
return $this->getOrganizationAttribute();
}
public function getOrganizationAttribute(): ?Tenant
{
return app()->bound('currentTenant') ? app('currentTenant') : null;
}
Scopes
Keep every default scope. Each one is load-bearing, and dropping one fails quietly for every tenant:
- onboarding.read — without it
getOnboardingStatus()fails with a 403 and stays null, so the dashboard cannot warn about an unverified account. - settlements.read — without it the settlement sync gets a 403 for every tenant and no payout can be traced to its payments; see Settlements. A published config that predates it is warned about at boot.
- refunds.read and refunds.write — without refunds.write
RefundService::refund()gets a 403 for every tenant. Without refunds.read no tenant refund ever completes in the package: the webhook's refund sync gets the 403 too, so there is no event and no credit note, even for a refund made in the Mollie dashboard. See Refunds. Warned about at boot and bylarsasub:doctor.
Mollie grants scopes when a tenant authorises, so a scope you add later reaches existing connections only after each tenant re-authorises — see Re-authorising a connected tenant.
The OAuth flow
How a tenant connects their Mollie account. The bundled tenant-connect dashboard drives it; a headless host links the tenant owner to the authorize route and handles the return.
- The tenant owner opens the tenant connect dashboard and clicks "Connect
Mollie" →
larsasub.mollie.connect.authorize(throttled, owner-only). - LarsaSub redirects to Mollie's authorization page.
- The tenant approves and Mollie redirects to
larsasub.mollie.connect.callback. - The callback does not complete the connection. It stashes a single-use ticket and bounces the browser to a return URL on the authenticated origin.
larsasub.mollie.connect.completeruns there, re-authenticates the initiating user, and only then exchanges the authorization code.- Organization info and the website profiles are fetched from Mollie; the tokens
are stored encrypted in
larsasub_mollie_connected_accounts. - The browser lands on
redirect.connect_callback(the tenant connect dashboard by default) withconnect_success=1, or with aconnect_error:
connect_error |
Meaning |
|---|---|
denied |
The tenant declined at Mollie |
organization_changed |
A reconnect to a different Mollie organization was refused — see the organization-change guard |
failed |
Anything else went wrong on the way; the log says what |
Only the tenant's owner gets through authorize, complete and the
disconnect routes: the user whose key equals
the tenant's connect.tenant_owner_attribute. In the default auto resolution
mode the tenant is the one the user's billable owner names through
connect.tenant_relation, and the tenant the request names must be that one.
In explicit mode the tenant key the request
carries (in the authorize URL, the disconnect request, or the ticket
complete reads) is looked up as a billable.platform_model; without that key
set, the routes answer 404.
The callback/complete split exists for two reasons. The callback arrives on a
different origin than the one the user authorized from — not just in the
localhost-authorize / ngrok-callback development split, but in production too:
a Mollie Connect app registers one redirect URI, so the callback always
lands on config('app.url'), while authorize() runs on the host where the
tenant owner is logged in. The session, and therefore the initiating user, is
not reliably known there. And a state value alone must never be enough to
attach a Mollie organization to a tenant; that would be an account-linking hole.
Where the flow state lives
The state those two hops share is a row in larsasub_connect_flow_states, on
larsasub.database.connection — deliberately not in the cache. There is
nothing to configure.
Both origins have to read the same storage, and the application's default cache
store cannot be assumed to have that property. A per-tenant database cache, a
prefix that varies with the tenant, or a failover store whose fallback differs
depending on whether a tenant connection is reachable all produce the same
failure: authorize() writes where callback() cannot read, and connecting
dies with 403 Invalid or expired state parameter — which reads like an expired
link rather than a configuration fault. The package connection does have the
property: if it were tenant-scoped, billables, charges and invoices would sit in
per-tenant databases and nothing else here would work either.
Spending a state and issuing a completion ticket happen in one transaction, as does consuming that ticket — a locked row, written or deleted by the same transaction that read it. That is what makes both single-use, and it means a request that dies mid-flow leaves something that can still be finished instead of a state that was spent for nothing.
A row holds the tenant reference, the initiating user's identifier, and the hash
of whichever credential is currently live — SHA-256 of the state until the
callback, SHA-256 of the completion ticket after it, never both at once, because
spending the one is what issues the other. From the callback onward it also
holds the authorization code, encrypted at rest. The row is deleted as soon as
the flow completes or is declined.
Set
standalone.domain, or run Laravel'sTrustHosts. The origin the ticket is handed back to comes fromstandalone.domainwhen that is configured. Leave it unset and it is taken from the request's Host header, which nothing validates unless your application runsTrustHosts— and that origin is wherecomplete()receives the ticket. The package logs a warning when neither is in play.
An abandoned row stops being usable after 30 minutes (five once a ticket is
issued — every lookup filters on expiry) and is physically removed by a sweep in
the next authorize(), which is why there is no prune command and no retention
setting. See Compliance.
Re-authorising a connected tenant
Mollie grants scopes when the tenant authorises. A scope added to
connect.scopes later reaches an existing connection only when the tenant owner
runs the flow once more. That is the same route as the first connect: the
one connect.authorize_route names, by default the package route
larsasub.mollie.connect.authorize (owner-only, throttled, {tenant} in the
URL). It accepts a tenant that is already connected, and running it there is
safe:
- Same Mollie organization — the connected account is updated in place:
new tokens carrying the configured scopes, the stored website profile and the
tenant's profile choice kept, customers, mandates and subscriptions untouched.
connected_atbecomes the moment of the re-authorisation. Ascope_missingorunauthorizedinsettlements_sync_erroris cleared, because it was about the old credential; the next sync records it again if the new grant still lacks the scope. A draining disconnect is called off. - Another organization — refused by the
organization-change guard: the existing
connection stays exactly as it was and the tenant returns with
connect_error=organization_changed. - Declined at Mollie, or a failure on the way — nothing changes;
connect_error=deniedorconnect_error=failed.
In the bundled tenant-connect dashboard a connected tenant owner gets
"Re-authorize Mollie" beside "Disconnect", with a line saying what it does
(hidden while a disconnect drains: finishing the flow would call it off).
When the settlement sync recorded scope_missing or unauthorized for the
account, a notice explains why and puts the same action up front.
Headless hosts offer it on their own page:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieConnectService;
$url = route(config('larsasub.connect.authorize_route'), $tenant);
$status = app(MollieConnectService::class)->getStatus($tenant);
if ($status['reauthorization_needed']) {
// $status['reauthorization_reason'] is 'scope_missing' or 'unauthorized':
// put the link up front.
}
The flow returns to redirect.connect_callback with connect_success=1 or a
connect_error. getStatus() reads the database only, so it is free on every
page load. Offer the link unprompted as well, the way the bundled dashboard
does. After a successful return, SettlementService::syncTenant($tenant) syncs
the tenant straight away instead of at the next scheduled run.
Choosing the website profile
A Mollie organization can hold several website profiles — one per site or activity, and plenty of small businesses have three. The profile a payment is created on decides two things the tenant cares about:
- the name and logo on the Mollie checkout screen their customer sees;
- which payment methods are enabled, since Mollie activates methods per profile.
The package cannot know which brand a tenant means, so it does not decide. On a fresh connect it stores the first profile — a connect must never leave a tenant unable to collect — but it records that this was its pick, not a choice:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieConnectService;
$service = app(MollieConnectService::class);
$status = $service->getStatus($tenant);
if ($status['profile_choice_pending']) {
// Several profiles, and nobody has chosen: offer the picker.
}
foreach ($service->listProfiles($tenant) as $profile) {
// $profile->id, ->label(), ->website, ->status, ->current
}
$service->setProfile($tenant, $chosenId);
getStatus() reads the database only, so it is free on every page load;
listProfiles() is the call that talks to Mollie — fetch it when the picker
opens, not on render.
Three things worth knowing when you build that UI:
profile_countis a snapshot, taken at the last connect and refreshed bylistProfiles(). A tenant who adds a website at Mollie afterwards keeps a stale count, soprofile_choice_pendingstays false. Show the active profile with a "change" action regardless; treat the flag as a nudge, not as the only way in.- A reconnect never moves a tenant to another profile. It only picks when nothing is stored, when the stored profile no longer exists at Mollie, or when the previously stored organization was unknown (see the guard below).
- Switching does not touch customers or mandates. At Mollie those live on
the organization, not on a profile, and payments already in flight keep the
profile they were created with. Enabled payment methods DO differ per
profile, though: after a switch
setProfile()re-checks what the tenant has switched on and writes any method that is not active on the new profile to the account's model log. It does not turn anything off — what a tenant offers is their decision — so act onMollieProfileChangedEventif you want more than a log entry.
setProfile() refuses a profile that is not one of the connected
organization's, and one Mollie has blocked. An unverified profile is
accepted: a brand-new organization has nothing else, and whether the account can
take live payments is what onboarding status answers.
Failures, in setProfile() and listProfiles() alike, are a
MollieProfileException.
Onboarding status
Before a tenant goes live, check that Mollie has finished verifying their organization. Payment methods can look active on a connected account's profile while the organization has not finished Mollie's verification. Such an account cannot receive live payments: payment creation fails with "The payment method is not activated on your account".
$onboarding = app(MollieConnectService::class)->getOnboardingStatus($tenant);
// ['status' => 'needs-data|in-review|completed',
// 'can_receive_payments' => bool, 'can_receive_settlements' => bool]
// null when not connected, or when the status could not be fetched
The tenant connect dashboard shows a warning when can_receive_payments is
false. This call talks to Mollie, and needs the onboarding.read scope.
Application fees
The platform's cut of a tenant's payment, sent to Mollie as the payment's
application fee. Switch the global fee on with connect.fee.enabled = true:
- Percentage — a share of the payment, rounded to the cent (10% of €50 = €5.00)
- Fixed — a flat amount per payment (€0.25), never more than the payment itself
- Per-plan override — a
Subscriptionwith bothfee_typeandfee_valueset uses those instead of the global fee
A per-plan override applies even with
connect.fee.enabledoff. It is set by the operator, in code or an admin panel — the bundled tenant plan dashboard does not accept the fee fields, so a tenant cannot zero the platform's fee on their own plans.
Which payments carry it:
| Payment | Fee |
|---|---|
| A subscription's first payment, renewals and the manual recovery of a failed charge | The plan's override, else the global fee |
A one-time SinglePayment and a standalone charge |
The global fee |
| The €0,01 mandate verification | None |
| A chargeback follow-up | None by default — see below |
Fees are skipped automatically when:
- the billable is the
billable.platform_modelitself (self-transactions), or - the tenant's Mollie organization id matches
connect.platform_organization_id— Mollie does not allow charging fees on your own account, or - the fee works out below €0.01.
Every payment stores what it carried (platform_fee_amount,
platform_fee_type, platform_fee_percentage), and the tenant fees dashboard
(billing/admin/fees) shows a tenant what was charged.
A chargeback follow-up carries no fee by default. Mollie documents that
application fees are not refunded and that a chargeback is debited from the
connected account. The original payment's fee therefore stays on the platform's
balance when it is charged back, and a fee on the follow-up would charge the
tenant twice for one period. Setting
dunning.chargeback_follow_up_application_fee to true charges it anyway —
for a platform that gives its fee back on a chargeback itself.
A refund does not return the fee. Mollie keeps application fees on the
platform's balance when a connected account refunds ("non-refundable"), and the
package does not move them back — platform_fee_amount stays booked on the
payment. A refund needs the refunds.write scope, which the default
connect.scopes include. See Refunds.
The platform keeps its fee — on a refund and on a chargeback. That is a decision, not a gap: by then the platform processed the payment and ran its work, and the amounts are small. The package moves no money between balances (no Balance Transfers). Say so in your terms with your tenants.
Dual billing
In dual billing the platform also bills the tenants themselves — for using the platform — on its own Mollie key, while the tenants bill their users through Connect. Name the model the platform bills:
'billable' => [
'model' => \App\Models\User::class, // tenants' users → the tenant's Connect account
'platform_model' => \App\Models\Organization::class, // the platform bills this → the global key
],
MollieClientResolver routes on that distinction automatically. Both models must
implement Contracts\BillableModel and use Traits\IsBillableModel.
What a tenant pays the platform never runs through Connect: no application fee,
company mail about it goes to the platform's own notifications.sales, and a
tenant's disconnect does not touch it.
Disconnecting a tenant
A tenant who leaves, or wants to stop collecting through your platform, disconnects their Mollie account. A disconnect is requested, and the account drains first:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieConnectService;
$service = app(MollieConnectService::class);
$impact = $service->disconnectImpact($tenant); // read only: the numbers for a confirm dialog
$impact = $service->requestDisconnect($tenant); // Support\DisconnectImpact
Draining
The connection stays — tokens included — until every payment of the tenant
has a final status, so the webhooks book each of them the ordinary way, and
refunds and the settlement sync keep working. Nothing new starts for the
tenant meanwhile: no collection, no checkout, no subscription or plan change,
no retry of a first payment, no standalone charge, no €0,01 verification. Each
of those refuses with Exceptions\ConnectDisconnectPendingException before
anything is written (the bundled pay buttons answer 422, as for a disconnected
tenant), and the log records the refusal ("New payment refused", with the
billable). The execution loops leave the tenant's planned charges alone —
unclaimed, unflagged and unmailed — so they are collected as usual if the
drain is called off. Booking is never refused.
larsasub:notify-charges does not skip those charges: a subscriber can still
get the advance notice of a debit that waits, and that never comes if the
connection is removed.
No customer mail gives them a button to a refused payment. While the
tenant drains,
the payment-failed, first-payment-failed and chargeback mails and the payment
reminder leave out their button to the bundled dashboard, where the payment or
the retry would be refused. A notifications.payment_url or
notifications.first_payment_url of your own is still used.
The last pass of larsasub:execute-charges removes the connection once
nothing awaits its outcome any more (MollieDisconnectedEvent, drained
true). With nothing under way at the request, it is removed in the same call.
At the latest after connect.disconnect_drain_days (default 30, frozen at the
request) it is removed anyway, and what is still under way is flagged as
below.
Without the scheduler a drain never ends on its own. That is the safe side — nothing is lost — but a headless host that does not run the scheduler calls
finalizeDisconnects()itself.larsasub:doctornames every drain past its deadline.
| Call | What it does |
|---|---|
disconnectImpact($tenant) |
Read only. What is under way (recurringCharges, singlePayments, openPayments(), oldestSince), and a drain's requestedAt / deadline — the numbers for a confirm dialog |
requestDisconnect($tenant) |
Starts the drain (MollieDisconnectRequestedEvent with the open count), or returns the running one unchanged — a second request does not move its deadline. disconnected true when nothing was under way and the connection went at once. A tenant without a connected account gets all false and zero, here and from disconnectNow() |
cancelDisconnect($tenant) |
Calls a drain off (MollieDisconnectCancelledEvent); new payments start again. false when nothing was draining. Finishing the connect flow again for the same organization cancels it too |
disconnectNow($tenant) |
The tenant's right to leave at once, drain or not: flags what is under way and removes the connection (MollieDisconnectedEvent, drained false, with the flagged count) |
finalizeDisconnects() |
The scheduled pass (Support\DisconnectFinalizeResult). Per draining account, inside its tenant's context: drained → removed; past the deadline → flagged and removed; otherwise it waits |
In the bundled tenant-connect dashboard "Disconnect" opens a dialog that
names what is under way and offers two choices: once everything is
processed (the default — a drain) and now, which says that the money of a
payment under way can still reach the tenant's Mollie balance but is flagged
here. Both say that no new payments are collected through Mollie once the
connection is gone. While a drain runs the page shows a banner — requested
on, how many are still under way, and the deadline from which it goes in any
case (the first scheduled run after it removes the connection) — with
Cancel disconnect and Disconnect now. The subscribe section of the
subscriber dashboard steps aside while the tenant drains. Headless hosts call
the methods above, or the
disconnect and cancel-disconnect routes.
Removing the connection revokes the tokens at Mollie (best-effort, outside every transaction) and marks the account disconnected. From then on the package holds no credential for that Mollie account, so it can neither start a payment there nor read one. Call these methods outside your own transactions.
The account row stays, with the tenant's Mollie organization and its
reference_code. Invoices and credit notes issued for the tenant afterwards
keep its prefix (Numbering), and connecting a
different organization later still takes
the reset.
The tenant's subscriptions do not end with the connection. They go on
planning charges that larsasub:execute-charges can no longer collect: each
one is flagged for an operator, and its pay button answers 422. When a tenant
leaves for good, end its subscriptions and close its reminder series
(DunningService::close()).
"Under way" means awaiting a final status — not "nothing can happen any more": a refund or a chargeback can still arrive after the removal, and is flagged as below. Platform billables in dual billing are never part of a drain: what a tenant pays the platform runs on the platform's key, during the drain and after it.
What is flagged when the connection goes
What is still under way when the connection goes is flagged first — at
once, or at a drain's deadline. Every payment of the tenant that still awaits
its outcome — a collection claimed without an answer yet, a SEPA debit on
pending, an open checkout, a €0,01 mandate verification — gets
needs_attention with an attention_message that opens with
ProviderPaymentModel::CONNECT_DISCONNECTED_MARKER (connect:disconnected).
Its money may still reach the tenant's Mollie balance, but nothing here can book it any more: only the tenant can see in Mollie whether it was paid, and an operator settles it by hand.
- The selection keys on the tenant stored on the registration
(
tenant_type/tenant_id): platform billables in dual billing are never touched, and a registration without a stored tenant is not flagged by the disconnect (its webhook still is, below). - A collection that provably created nothing (
CREATED_NOTHING_MARKER) keeps its own flag and its pay button. - The flags write no company mail per payment: the tenant's admins get
one mail per removal with payments under way
(
notifications.company.connect_disconnected_open_payments, on by default), and the account's History says how many were flagged.
A webhook that arrives afterwards is answered 200. Mollie keeps calling
the webhook URL of a payment whatever happened to the connection — a status
change, a refund, a chargeback. Without a token it cannot be read, so instead
of a 500 the record is flagged the same way and Mollie gets a 200. This also
covers a paid payment: a webhook about one is a refund or a chargeback
nobody can book now. The first delivery that flags a record raises
PaymentNeedsAttentionEvent (the usual company mail); a redelivery changes
nothing. An unknown payment, or a tenant without any connected account at all,
still answers 500 — those are transient or a misconfiguration, and Mollie's
retries are the window to fix them in.
No customer hears about a flagged payment. The marker keeps the pay button away (the payment may be running), a flagged failed collection never opens a reminder series, and a series already running pauses on it.
The flag comes off by itself only when a final status is booked after all
(paid, failed, canceled, expired) — after the tenant reconnects to the
same organization and a webhook reads the payment. Nothing is fetched again on
a reconnect: a record whose news was lost while disconnected keeps its flag
until an operator settles it.
Losing a connection
A connection can also end without anyone asking: the tenant revokes access in Mollie's own dashboard, or the refresh token dies.
MollieConnectedAccount::refreshIfNeeded() refreshes the access token under a DB
lock. A 4xx auth rejection of the refresh grant — not a timeout, a 429 or a
5xx, which are transient — marks the account disconnected and dispatches
MollieConnectionLostEvent. That is how a revocation in Mollie's dashboard
reaches the package, so it follows the same path as
a disconnect: what is still under way is flagged
first, and later webhooks are answered 200. The delivery during which the
refresh is refused still answers 500; the next one finds the account
disconnected.
That event notifies the tenant admins
(notifications.company.connect_connection_lost, on by default). Without it,
charges quietly park as needs_attention while the dashboard still says
"connected". When the revocation left payments flagged, the same admins also
get the one mail about those
(notifications.company.connect_disconnected_open_payments). A revocation
ends a drain as well: the account is disconnected, and nothing waits any
more.
The organization-change guard
handleCallback() refuses a reconnect whose authorized Mollie organization
differs from the stored one — or cannot be verified against it.
Customers and mandates live inside one organization, so a silent switch would
orphan all of them and stop collection while the dashboard cheerfully reports
"connected". A different organization throws MollieOrganizationChangedException
(the dashboard receives connect_error=organization_changed); an organization
that cannot be determined throws PaymentProviderServiceException
(connect_error=failed). Either way the refused tokens are revoked
best-effort and the existing connection stays as it was.
An intended switch is an explicit admin action:
php artisan larsasub:connect-reset {tenant_type} {tenant_id}
The command asks for confirmation; --force skips the prompt. tenant_type
is the tenant's morph alias or class name. It runs
resetForOrganizationChange($tenant), which disconnects at once (flagging
the old organization's payments still under way, see
Disconnecting a tenant), archives the local
mandates and customers, clears the organization and profile markers and
re-arms the connect flow. It returns how many mandates and customers it
archived.
After a reset every subscriber must re-authorize their mandate. There is no way around that — the old mandates live in the old organization.
Settlements
Mollie pays out in bundles: one transfer to the bank account, under one
reference, covering many payments. larsasub_settlements mirrors those payouts,
one row per settlement (Models\Settlement), and every package payment that
was in one carries its settlement_id — so a reference on a bank statement
leads to the payments it paid out, instead of being booked a second time
against invoices the webhook already marked paid.
The rows belong to the connected account they were paid out from
(tenant_type/tenant_id); null is the platform's own account. amount is
what Mollie paid out, net of its costs, and periods is Mollie's own
revenue/costs breakdown — the package recomputes neither. Book the difference
between your invoices and the payout as costs from periods, not from a sum of
your own.
The sync
larsasub:sync-settlements runs daily (scheduling.sync_settlements_frequency)
and looks back 60 days (scheduling.sync_settlements_lookback_days, or
--days). --account= limits a run to one account: a connected account's
reference_code, or platform.
Mollie sends no webhook for payouts, so this is a pull: per account, the settlements in the window, then per settlement the payments in it — one call per payout rather than one per payment — and its captures (Klarna and other capture-based methods are listed there). A payout goes open → pending → processing → paidout over a few days; a settlement that is not final yet is re-read every run.
Mollie lists a payout's payments per website profile. For a connected account
the sync asks every profile of the tenant's organization, so a tenant that
switched profile (MollieConnectService::setProfile()) still has the payments
made on the old one attached; this uses the profiles.read scope the connect
flow already requests. For the platform's own account it asks the profiles you
configured (settlements.platform_profile_id, and each connected account that
is the platform's own organization).
Which accounts, in this order:
- The platform's own account first, when
settlements.sync_platformis on. The platform pass leaves the tenant context itself (yourconnect.tenant_contexthook is called withnull), so its events never reach your listeners inside a tenant's context, and callingSettlementService::syncPlatform()aftersyncTenant()in the same process is safe. - Every connected account when Connect is enabled.
The platform's own account is the only account in simple mode, and where the
platform's own charges settle in dual billing. sync_platform is off by
default because Mollie serves the Settlements API to OAuth and organization
access tokens only, never to an API key: create an organization access token
with settlements.read and payments.read, and set
LARSASUB_SETTLEMENTS_PLATFORM_TOKEN (plus
LARSASUB_SETTLEMENTS_PLATFORM_PROFILE_ID, which Mollie requires with an
organization token).
In dual billing, a connected account that IS the platform's own organization
(connect.platform_organization_id) reads the platform's payouts: they are
recorded as the platform's (tenant null) whichever credential read them, with
the payments of that tenant's subscribers attached next to the platform's own.
With sync_platform on and the organization token set, that account is
skipped and the platform pass covers it; without the token the account keeps
reading them. Set connect.platform_organization_id before that account's
first sync: payouts it recorded as a tenant's before the id was set stay that
tenant's, and are reported as owner_conflict afterwards.
Each account is handled on its own, and within an account each payout. An
account that cannot be synced is logged and gets a short code in
settlements_sync_error on its connected account, and the run goes on with the
next one. What each code means and what to do about it is the table in
UPGRADE.md. The one to expect on an
existing installation is scope_missing — a tenant that authorised before
settlements.read was requested. A missing scope never touches the
connection: payments keep working.
A payout already recorded for another account is never taken over: the account
reading it again gets owner_conflict, and the table says what to do. The
common cause is the platform's own organization connected as a tenant while
connect.platform_organization_id is unset — set it.
A successful pass clears the code and stamps settlements_synced_at. A payout
that is still open when it drops out of the window is followed anyway, until
Mollie reports it final. When a payout FAILS (a closed bank account), Mollie
pays its payments out again in a later payout, and they move there — also when
the earlier payout was stored as paid out and only failed afterwards: the sync
asks Mollie about the earlier payout before leaving a payment on it.
A final payout is not walked again. If one was completed while payments were
missing — for example after settlements.platform_profile_id changed, or
after re-owning rows for an owner_conflict —
larsasub:sync-settlements --resync (with a --days that reaches it) walks
the payments of every paid-out payout in the window once more and attaches
what was missed; syncTenant($tenant, resync: true) and
syncPlatform(resync: true) do the same. It announces nothing a second time:
re-read payments() if you booked from SettlementRecordedEvent.
larsasub:doctor lists every account with a code, and every connected account
the scheduled sync has never reached. In test mode (MOLLIE_KEY=test_…) every
account is skipped: Mollie has no test payouts.
Existing connections do not have a scope you add later. Mollie grants
scopes at authorisation time; adding settlements.read to the config changes
nothing for a token that already exists. Each tenant re-authorises once,
through the same authorize route as the first connect — safe on a connected
tenant, and offered by the bundled tenant-connect dashboard; a headless host
links to it itself. See
Re-authorising a connected tenant.
After that, SettlementService::syncTenant($tenant) syncs them without waiting
for the next run.
Reading it back
use PetersDevelopment\LarsaSub\Service\SettlementService;
$settlements = app(SettlementService::class);
// The line on the tenant's bank statement → the payout and its payments
$payout = $settlements->findByReference('1234567.2609.03', $tenant); // null = the platform's own account
$payout->amount; // what Mollie paid out
$payout->periods; // Mollie's revenue/costs breakdown
$payout->payments(); // RecurringCharge and SinglePayment models in it
$charge->settlement; // from the other side
// One account's payouts, as a query to refine (null = the platform's own)
$settlements->forTenant($tenant)->where('status', 'paidout')->get();
// By Mollie's settlement id (stl_…) — not scoped: check ->tenant before showing it to a tenant
$payout = $settlements->findBySettlementId('stl_xxxxx');
Every read is scoped to one account and never widens: null is the platform's
account only, a tenant is that tenant's payouts only. The one exception is
findBySettlementId(), because Mollie's ids are unique across accounts.
Payments made outside the package are not in payments(), and refunds and
chargebacks are not attached — Mollie's amount and periods include all of
them.
Two events, SettlementRecordedEvent and SettlementStatusChangedEvent,
announce a new payout and a status change, after its payments were attached;
book on paidout. See Events.
MollieConnectService
The service behind the OAuth flow, the profile picker and disconnecting. All of it is reference for what the sections above describe.
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieConnectService;
$service = app(MollieConnectService::class);
// Start the flow. Returns ['url' => ..., 'state' => ...]
['url' => $url, 'state' => $state] = $service->getAuthorizationUrl();
// Complete it — the TENANT comes first, then the authorization code
$account = $service->handleCallback($tenant, $code);
// Connection status — reads the stored registration only, no Mollie call
$status = $service->getStatus($tenant);
// ['connected' => bool, 'organization_name' => ?string, 'connected_at' => ?string,
// 'profile_id' => ?string, 'profile_name' => ?string, 'profile_count' => ?int,
// 'profile_selected' => bool, 'profile_choice_pending' => bool,
// 'reauthorization_needed' => bool, 'reauthorization_reason' => ?string,
// 'draining' => bool, 'disconnect_requested_at' => ?string, 'disconnect_deadline_at' => ?string]
// Mollie's own verification status for the connected account (calls Mollie)
$onboarding = $service->getOnboardingStatus($tenant);
// The organization's website profiles (calls Mollie)
$profiles = $service->listProfiles($tenant);
// list<Support\MollieProfile>: id, name, website, mode, status, current
// Select the profile payments are created on
$service->setProfile($tenant, 'pfl_xxxxx');
// Disconnecting — see "Disconnecting a tenant"
$impact = $service->disconnectImpact($tenant); // read only: what is under way
$impact = $service->requestDisconnect($tenant); // drain first (the default)
$service->cancelDisconnect($tenant); // call a drain off: bool
$impact = $service->disconnectNow($tenant); // at once; flags what is under way
$result = $service->finalizeDisconnects(); // the scheduled pass, if you run it yourself
// The explicit escape hatch for an organization switch
$counts = $service->resetForOrganizationChange($tenant); // ['mandates' => int, 'customers' => int]
Signatures:
getAuthorizationUrl(?array $scopes = null, ?string $redirectUri = null): array
handleCallback(Model $tenant, string $code, ?string $redirectUri = null): MollieConnectedAccount
getAuthorizationUrl() requests connect.scopes when you pass none. The
bundled routes pass the callback route as $redirectUri; when you run the
exchange yourself, pass the same redirect URI to both calls.
Tenant-aware models
Which models carry a tenant, and how to query them. Most tenant scoping runs
through the shared ScopedToTenant trait, with an explicit null policy:
Subscription::forTenant($tenant)->get(); // strictly the tenant's plans
Subscription::forTenant($tenant, includeGlobal: true)->get(); // plus the global plans
Subscription::forTenant(null)->get(); // global (tenantless) plans only
| Model | Tenant columns | Scope |
|---|---|---|
Subscription, Invoice, CreditNote, Settlement, TenantPaymentMethod, Billable |
tenant_type / tenant_id |
ScopedToTenant::forTenant() as above; null means global |
MollieCustomer |
tenant_type / tenant_id |
Its own forTenant($tenant), for one tenant |
MollieMandate |
None | Through its customer: belongsToTenant($tenant) |
InvoiceSequence |
tenant_type / tenant_id / tenant_key |
One numbering sequence per tenant |
Every tenant-scoped query must carry the tenant. A query that forgets it reaches another tenant's customers and mandates.
Billable registrations are tenant-scoped too, which is what makes owner models in per-tenant databases workable — see Billable models and Multi-database tenancy.
Admin dashboard authorization
Who may open the management dashboards (plans, subscribers, charges, invoices, fees). They are mode-aware:
- Connect mode — the authenticated user must be the owner of the tenant,
resolved by comparing the tenant's
connect.tenant_owner_attributecolumn against the user's primary key. Anything else gets a 403, and all data is scoped to that tenant. The Mollie-connection and application-fee dashboards exist only in this mode. - Simple mode — there is no tenant concept, so the same dashboards act as
operator management over the global plans and their subscribers, charges
and invoices. Authorization runs through
larsasub.policies.admin_role. With no admin role configured, nobody passes; a denied request logs an explanatory line.
Two consequences to plan around:
- Single owner, no roles. Exactly one user — the one the owner attribute points at — can manage a tenant's connection, plans and subscribers. There is no delegation or role support yet. If your app has multi-admin tenants, route your own authorization in front and keep the owner attribute pointing at a designated account.
- The owner attribute must be right. A wrong attribute name locks everyone out, silently, and nothing checks it at boot. Verify it when you wire up Connect.
Notification routing
Who receives which mail in Connect mode.
- User notifications go through
NotificationRecipientResolver. In Connect mode the mails show the tenant's name (connect.tenant_name_attribute) instead ofconfig('app.name'), and use the tenant's email theme when the tenant model implementsgetEmailTheme(). - Company notifications go through
CompanyNotificationResolver, to the tenant's admins: the users your billable owner model returns fromgetTenantNotifiableUsers()(empty by default), or else the tenant's owner (connect.tenant_owner_attribute). In dual billing the platform's own charges to a tenant are the exception: the platform is the seller there, so their company mail goes tonotifications.sales, never to that tenant's admins. - Platform notifications — with
connect.notifications.also_notify_platform = true, the global sales team receives company notifications alongside the tenant. They also receive the copy when the tenant side could not be reached at all. - The admin button in a company mail resolves per audience. The same
notification reaches two administrations, and they are not the same system:
an admin-panel package (larsasub-nova, larsasub-filament) answers for the
platform, and a tenant's own environment is yours to name through
notifications.tenant_admin_url. Without it the package falls back to a bundled tenant dashboard, and failing that sends no admin button rather than a link into the operator's panel. See The admin button knows who is reading. - The button in a SUBSCRIBER's mail is the same question one audience over.
That mail already carries the tenant's name and the tenant's colours, so
config('app.url')under it is the one value that does not follow the tenant. Name the tenant's own environment throughnotifications.user_app_url, whose invokable receives the billable. See The subscriber's button. - Reminders run on one schedule for every tenant. With
dunning.reminders.enabledon, the customers of every tenant are reminded of their unpaid charges under the tenant's name and theme, on the one globaldunning.reminders.steps. While a tenant drains a requested disconnect, its reminders carry no button to the bundled payments dashboard, which could not start a payment — and neither do its payment-failed, first-payment-failed and chargeback mails (Draining). A tenant that disconnected its Mollie account can take no payment through the package at all, but a reminder that still goes out then keeps its pay button, which leads to a refusal unless yournotifications.payment_urlsends the customer to a route of your own (used during a drain too). Close those series (DunningService::close()) when a tenant leaves.
Both funnels queue their work through LarsaSub's own delivery job rather than
Notification::send(), so viaQueues(), viaConnections(), withDelay() and
notification-level job middleware have no effect on them. Why, and what that
buys, is in Multi-database tenancy and
Notifications.