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

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=platform before 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. Run php artisan larsasub:license to 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

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_attribute must 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:

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.

  1. The tenant owner opens the tenant connect dashboard and clicks "Connect Mollie" → larsasub.mollie.connect.authorize (throttled, owner-only).
  2. LarsaSub redirects to Mollie's authorization page.
  3. The tenant approves and Mollie redirects to larsasub.mollie.connect.callback.
  4. 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.
  5. larsasub.mollie.connect.complete runs there, re-authenticates the initiating user, and only then exchanges the authorization code.
  6. Organization info and the website profiles are fetched from Mollie; the tokens are stored encrypted in larsasub_mollie_connected_accounts.
  7. The browser lands on redirect.connect_callback (the tenant connect dashboard by default) with connect_success=1, or with a connect_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's TrustHosts. The origin the ticket is handed back to comes from standalone.domain when that is configured. Leave it unset and it is taken from the request's Host header, which nothing validates unless your application runs TrustHosts — and that origin is where complete() 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:

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 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:

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:

A per-plan override applies even with connect.fee.enabled off. 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:

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:doctor names 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.

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 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:

Two consequences to plan around:

Notification routing

Who receives which mail in Connect mode.

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.