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

Upgrade Guide

Breaking or behavior-affecting changes per release. Routine updates (composer update within your constraint) need no action unless listed here.

Since 0.37.1, existing migrations never change. Every schema change ships as a new migration, so php artisan migrate is all an upgrade needs. Up to 0.37.1 the package edited its migrations in place: a database created from an earlier 0.x release can still miss columns those edits added. Run php artisan larsasub:doctor once to find them; the release sections below say what each edit added, and migrate:fresh fixes a development database.

0.38.2

No schema change.

0.38.1

No schema change.

0.38.0

No schema change.

0.37.1

getNextBillingDate() answers null for what the planner does not collect

No schema change and no config change. BillableSubscription::getNextBillingDate() (and with it the dashboard's next_billing_date and the "Next payment" row in the activation and started mails) changes in two ways:

0.37.0

Subscription charges are planned on their collection day

No schema change and no config change. larsasub:plan-charges now creates a subscription's charge on the day it is collected instead of on the 1st of that month, so a cost line added or ended before that day is in the charge. What that changes for you:

0.36.0

A Connect disconnect drains first: refresh your development database

larsasub_mollie_connected_accounts gains disconnect_requested_at and disconnect_deadline_at (both nullable). The migration is edited in place: run php artisan migrate:fresh on a development database, or add them by hand (on MySQL, with your table prefix: ALTER TABLE larsasub_mollie_connected_accounts ADD disconnect_requested_at TIMESTAMP NULL, ADD disconnect_deadline_at TIMESTAMP NULL). larsasub:doctor reports the columns while they are missing.

The bundled tenant-connect dashboard drains first

Only for hosts on the bundled dashboards. Headless hosts calling the services themselves have nothing more to do than the section above.

0.35.0

VAT numbers can be checked in VIES, and then decide the reverse charge: refresh your development database

larsasub_billables gains vat_check_status, vat_check_number, vat_check_reference and vat_checked_at (all nullable) and an index on (invoice_type, vat_check_status); larsasub_invoices gains customer_vat_check_reference (nullable). The migrations are edited in place: run php artisan migrate:fresh on a development database, or add them by hand (on MySQL, with your table prefix: ALTER TABLE larsasub_billables ADD vat_check_status VARCHAR(16) NULL, ADD vat_check_number VARCHAR(34) NULL, ADD vat_check_reference VARCHAR(255) NULL, ADD vat_checked_at TIMESTAMP NULL, ADD INDEX larsasub_billables_vat_check_index (invoice_type, vat_check_status) and ALTER TABLE larsasub_invoices ADD customer_vat_check_reference VARCHAR(255) NULL). larsasub:doctor reports the columns while they are missing.

Nothing changes until you switch it on: invoicing.vat_validation.enabled (LARSASUB_VAT_VALIDATION_ENABLED) is off by default, and off the package makes no outgoing call for it. A config published before this release lacks the invoicing.vat_validation block and the scheduling.check_vat_numbers_* keys and reads them as off / the defaults; copy them over to change them.

On, it changes amounts. The EU reverse charge then applies only to a number VIES confirmed; a business customer in another member state whose number is not confirmed is charged VAT (on inclusive prices nothing is stripped any more, on exclusive prices the VAT is added) and reclaims it. A charge keeps the decision of the day it was created.

Switching it on with existing customers — in this order:

  1. Check the numbers you already have: php artisan larsasub:check-vat-numbers --unchecked (add --limit= to spread a large base over several runs). It runs while the check is off and with the scheduler off.
  2. Set LARSASUB_VAT_VALIDATION_ENABLED=true. larsasub:doctor counts what is still unchecked.

The other way round, the next charge of every reverse-charged customer whose number is not checked yet carries VAT.

Before that, set LARSASUB_INVOICE_SELLER_VAT (simple mode) or return vat_number from your tenant's getInvoiceSellerDetails() (Connect): that number is the VIES requester, and without one VIES issues no consultation number. With the check on, the customer's VAT number goes to the European Commission — mention it in your privacy notice.

Run a queue worker. The check after a profile change is a queued job; on the sync queue it runs inside the request that saved the profile, after the commit (up to invoicing.vat_validation.timeout seconds, default 10).

Saving the profile and collecting in one go — a headless checkout — charges VAT on that first payment, because the check has not answered yet. Call VatNumberValidationService::checkNow($billable) between the save and the payment, outside your own transactions, when the first payment should be reverse charged (recipe). A checkout screen can read ChargeQuote::$vatNumberStatus to say which of the two it shows.

The bundled subscribe card names what it collects, and refuses what it cannot quote

Only for hosts on the bundled subscription dashboard (standalone.dashboards.subscription, on by default). Headless hosts calling the services themselves have nothing to do.

0.34.0

Connect: string-keyed users and the tenant-owner check

The tenant-owner check now compares as strings (see CHANGELOG.md, Security). Nothing to change in your code. If you run indirect or explicit billable mode with string (UUID) user keys, look through the mollie_connected_accounts table (with your prefix) and the account History for a connection or a disconnection nobody on the tenant's side asked for: before this release a member who did not own the tenant could reach those actions.

Dual billing: company mail about a platform invoice goes to the platform

When the platform charges a tenant (billable.platform_model), the company notifications about that tenant's subscription and payments now go to notifications.sales, also when the tenant model implements getTenantNotifiableUsers(). Before, that override was asked first and the tenant's own admins got the seller's copy of their own invoice. If you relied on them receiving it, send it from a listener on the event instead; the user-funnel mail to the tenant as customer is unchanged.

Invoice PDFs render in the customer's language, with that language's number format

The built-in invoice and credit-note PDFs now render in the billable's stored content locale, also when an operator downloads them, and print amounts and dates for that locale (nl, otherwise English separators — a billable in another comma-decimal language, such as de, now gets 1,234.56). On a single-language install that switches locale per request and stores no billable locale, set larsasub.locales.default to pin the document's language (details).

VatTreatment has a fourth value: stripped

A charge for a zero-rated customer (reverse charge, outside the EU) on VAT-inclusive prices, with invoicing on, now records vat_treatment = 'stripped' where it recorded excluded: the package removed the VAT from the listed price before collecting. ChargeQuote::$treatment says the same for that customer. Nothing to migrate; existing rows keep excluded.

A Connect disconnect flags the payments still under way

MollieConnectService::disconnect(), a refused token refresh (markAuthFailed()) and resetForOrganizationChange() now flag every payment of the tenant still awaiting its outcome: needs_attention, with an attention_message that opens with ProviderPaymentModel::CONNECT_DISCONNECTED_MARKER (connect:disconnected). A Mollie webhook for a payment of a disconnected account answers 200 and flags its record the same way, where it answered 500. Nothing to configure or migrate.

0.33.0

A collection whose outcome is unknown is no longer manually payable

A charge claimed for collection (charged_at set) without a provider status answers PaymentInProgress now, unless its attention_message opens with RecurringCharge::CREATED_NOTHING_MARKER — written when the failure provably created nothing (no mandate, a rejected request). Nothing to configure.

Reversed chargebacks are looked for: refresh your development database

larsasub_recurring_charges gains reversal_checked_at (nullable). The migration is edited in place: run php artisan migrate:fresh on a development database, or add the column by hand (on MySQL: ALTER TABLE larsasub_recurring_charges ADD reversal_checked_at DATETIME NULL, with your table prefix). larsasub:doctor reports it while it is missing. Until it exists, larsasub:execute-charges still collects, but skips the look described below and ends with a failure exit.

larsasub:execute-charges now asks the provider, once per run, about every chargeback follow-up younger than dunning.chargeback_reversal_check_days (default 60) whose chargeback is not yet seen reversed, and starting a follow-up's payment asks first — see Recurring charges. Nothing to configure; a published config without the key gets the default. Mind the one thing that changes for a headless host: do not wrap startManualChargePayment() in your own transaction — it now calls the provider before it locks anything.

dunning.double_paid defaults to signal

When the bank reverses a chargeback after its follow-up was paid, the customer paid the period twice. The package no longer refunds that by default: it flags the follow-up needs_attention, which mails the company, and you give it back — how, and what a refund on the follow-up does to its invoice, is in Recurring charges. Mollie very likely refuses a refund on a payment that was charged back, even after the reversal, so the old default ended as that flag anyway.

0.32.0

Reminder series: two new tables, off until you switch it on

larsasub_dunning_cases and larsasub_dunning_steps are new migrations: run php artisan migrate. The reminder series ships off (dunning.reminders.enabled); nothing writes to these tables until you switch it on — except a payment, which ends a series that was already running (see below).

0.31.0

Chargeback follow-ups: refresh your development database

larsasub_recurring_charges gains follows_charge_id, origin_chargeback_id (unique) and origin_reversed_at. The migration is edited in place: run php artisan migrate:fresh on a development database.

From this release a SEPA chargeback on a paid charge creates a follow-up charge the customer can pay again — on by default; set LARSASUB_DUNNING_CHARGEBACK_FOLLOW_UP=false to keep it off (see docs/recurring-charges.md#chargeback-follow-ups). It is never retroactive: chargebacks delivered before the upgrade keep no follow-up, and there is no backfill. A config you published from an unreleased main may have env(…, false) pinned — check the dunning block. What changes:

The chargeback mail and the dashboards: for every install

These apply whether or not dunning.chargeback_follow_up is on.

Refunds and a few behaviours

RefundService::refund(), CreditService::returnAppliedCredit() and CreditService::returnCollectedAsCredit() are new; nothing existing changed signature. These behaviours change without action:

Nova / Filament. Update larsasub-nova and larsasub-filament to 0.31 in the same composer update; both require ^0.31.

A custom payment provider needs nothing: the two new provider methods are not abstract. Without them its payments are refused with ProviderUnsupported.

Connect: refunds need refunds.read and refunds.write. Both are in the default connect.scopes; if you overrode that list, add them — boot and larsasub:doctor warn — and have tenants re-authorise. refunds.read matters beyond this release: without it no refund, not even one made in the Mollie dashboard, ever completes in the package.

RefundBlocker — what each refusal means

RefundServiceException::$reason is an Enums\RefundBlocker; the backed values are contract.

Value Meaning What to do
not_paid The payment was never paid Nothing to refund
no_provider_payment Paid without a provider payment: fully covered by credit, or €0,00 Give the credit back with CreditService::returnAppliedCredit()
mandate_setup A mandate-verification payment Not refunded through the package
invalid_amount Below €0,01 Ask for at least €0,01
nothing_refundable Everything collected already went back (refunds in flight included, standing chargebacks), or Mollie reports no amountRemaining Nothing left; check the payment in the Mollie dashboard if that surprises you
above_ceiling More than is still refundable Ask for less, or null for the rest
unreadable_history A stored refund or chargeback entry cannot be read, so the ceiling would be a guess Inspect provider_refund_details / provider_chargeback_details on the payment
provider_account_unresolvable Connect: the tenant cannot be resolved, is not connected, or its context hook failed Fix the tenant's connection; a hook failure may be transient — retry
provider_unsupported The payment's provider does not implement refunds Implement prepareRefund() / createRefund() in your provider
provider_refused Mollie answered with a 4xx other than 409; its message is appended Read the message — a missing refunds.write scope (or refunds.read, on a payment that already has refunds), or an amount above what Mollie allows

A timeout, a 5xx or a 409 (same key still in flight) is not a RefundServiceException: the Mollie exception is rethrown, because the refund may exist. Retry with the same idempotency key.

0.30.0

Breaking: invoice numbers are continuous, and the sequence table loses year

Invoice and credit-note numbers become INV-000001 / CN-000001 (Connect: LCRA-INV-000001): no year, no reset on 1 January, six digits.

0.29.0

Breaking only for hosts with a connect.tenant_context hook (per-tenant databases). Everyone else has nothing to do.

Breaking: connect.tenant_context also receives null

Until now the hook could only switch a tenant ON. A step LarsaSub ran for a registration without a stored tenant — the platform billable in dual billing, a simple-mode registration — left whatever tenant the previous step had activated. So in one charge run, one queue worker, one settlement sync or one Octane process, a platform step after a tenant step ran in that tenant's context: your synchronous listeners on LarsaSub events, an owner lookup, an IsPayable entity all read the previous tenant's database.

Since 0.29 LarsaSub calls the hook with null before such a step: leave the tenant context. Change the signature and add a null branch that undoes exactly what the tenant branch does.

Before:

public function __invoke(Model $tenant): void
{
    config(['database.connections.tenant.database' => $tenant->database]);
    DB::purge('tenant');
    app()->instance('currentTenant', $tenant);
    URL::forceRootUrl($tenant->url);
}

After:

public function __invoke(?Model $tenant): void   // or ?Tenant — your own class is fine
{
    if ($tenant === null) {
        if (! app()->bound('currentTenant')) {
            return; // already left — this is called before every platform step
        }

        config(['database.connections.tenant.database' => null]);
        DB::purge('tenant');
        app()->forgetInstance('currentTenant');
        URL::forceRootUrl(config('app.url'));

        return;
    }

    config(['database.connections.tenant.database' => $tenant->database]);
    DB::purge('tenant');
    app()->instance('currentTenant', $tenant);
    URL::forceRootUrl($tenant->url);
}

What the null branch must do, literally: undo everything your tenant branch sets — the tenant connection, the current-tenant binding, the root URL, and whatever else your tenant bootstrapping touches (a permission cache, a tenancy package's own bootstrapper; if you repoint config('database.default') per tenant, point it back). What it must not do: purge or repoint the connection LarsaSub itself writes on (larsasub.database.connection, or config('database.default') when that is null), or do network I/O — the prune calls the hook inside a LarsaSub transaction.

A hook that cannot take null is a boot fatal. LarsaSub refuses to boot — HTTP, queue:work, schedule:run and your test suite alike — with:

connect.tenant_context ('App\Tenancy\LarsaSubTenantContext') cannot be called the way LarsaSub calls it: its first parameter is not nullable. Since 0.29 the hook is called with one argument that is a tenant or null — … and give it a null branch that leaves your tenant context without throwing; …

The check reads the signature only. Whether your null branch actually works is not something it can see — and a branch that throws parks every platform charge (see below). Smoke-test it once after the change:

php artisan tinker --execute "app(config('larsasub.connect.tenant_context'))(null); echo 'ok';"

Only three shapes are refused: a first parameter that cannot take null (a union without null included), a builtin first type other than mixed/object, and a required second parameter. ?Model, ?Tenant, an untyped parameter and a union that includes null all boot.

Change the hook before composer update. ?Model works on 0.28 too, so the order is safe: edit the hook, deploy, then update the package. Update first and 0.29 refuses to boot until the signature is right.

A simple-mode host (connect.enabled false) with a Model-typed hook used to get only a boot warning; it now gets this fatal as well.

Behaviour: where null arrives, and where it does not

null arrives only on paths where LarsaSub is the one setting the context: the charge-execution and planning loops, the reconciliation and mandate-recovery passes, Mollie webhook processing, the notification job's user and company funnels, the settlement sync (the platform pass, and a connected account that is the platform's own organization), the prune's owner check for the platform billable, and larsasub:generate-missing-invoices.

What LarsaSub does inside your own web request on its own — an invoice download, a plan change that settles a charge locally — never sends null: your context stays. Two things do, wherever they run:

If you run the sync queue with a hook, that second one leaves your tenant for the rest of the request: a platform-billable notification — say, a tenant admin changing the tenant's own platform subscription on the tenant host — calls the hook with null once the transaction commits. It already switched your context the other way (a tenant notification on the platform host activated that tenant). Run a real queue connection when a hook is set.

A hook that throws on null is treated as the same transient failure as one that throws on a tenant, per path:

A tenantless registration in Connect mode is resolved through its owner, or refused. With Connect on and a hook configured, a registration that stores no tenant and is not the platform billable (typically one created before Connect was switched on) is handled like payment collection handles it: the entry points leave the context, ask the owner's tenant_relation, and run the step in that tenant. When the owner names none — its owner lives in a tenant database nobody can name — the step is refused, logged as "Registration carries no tenant and its owner names none": the charge parked, a customer notification dropped (a company one goes to the platform recipients only), reconciliation parked or deferred, mandate recovery skipped, and the webhook answered 500 — until Mollie stops retrying (about a day), after which that payment status is not fetched again. Before, all of these ran in whatever context happened to be active.

The planning loop (with a hook) does not plan such a subscription — nor one whose stored tenant no longer resolves — because the amount and the VAT decision are composed against the tenant, and a charge frozen without one could collect the wrong amount. It says so: larsasub:plan-charges prints "NOT planned: N subscription(s)" and logs an error per subscription. Repair the registration within that billing period — the next run then plans it; once the period has passed it is not billed automatically. (Before, both were planned in whatever context was active; a registration whose stored tenant no longer resolves then had its charge parked by the execution loop.)

Before you upgrade, find those registrations and store their tenant (substitute your table_prefix and the morph alias of your platform model):

SELECT id, owner_type, owner_id FROM larsasub_billables
WHERE tenant_type IS NULL AND owner_type <> 'your-platform-morph';

Set tenant_type, tenant_id and tenant_key ("{tenant morph}:{tenant id}") on each. If the unique owner key refuses the update, that owner already has a registration for the tenant — two registrations for one customer, which needs merging by hand rather than an UPDATE.

syncPlatform() after syncTenant() in one process is safe now, and a connected account that is the platform's own organization no longer needs its tenant row to be synced (it used to be skipped as tenant_context when that row was gone). The tenant_context settlement outcome gains a platform meaning; see the operator table under 0.28.0.

Hosts without per-tenant databases

Nothing to do: without a hook LarsaSub never calls one, and nothing changes — with one exception that is a fix: a connected account that is the platform's own organization is synced even when its tenant row is gone.

0.28.0

Settlements arrive: one new table, three columns added in place, one new OAuth scope and one new config block. Nothing existing changes behaviour.

Refresh your development database

larsasub_settlements is a new migration; migrate creates it. The three columns are added to existing create migrations, per the pre-1.0 rule, so larsasub:doctor reports them missing on a database migrated from an earlier checkout. Either migrate:fresh, or add them by hand (substitute your table_prefix). MySQL/MariaDB:

ALTER TABLE larsasub_recurring_charges ADD settlement_id VARCHAR(32) NULL AFTER provider_payment_status_raw;
ALTER TABLE larsasub_single_payments   ADD settlement_id VARCHAR(32) NULL AFTER provider_payment_status_raw;
CREATE INDEX rc_settlement_idx ON larsasub_recurring_charges (settlement_id);
CREATE INDEX sp_settlement_idx ON larsasub_single_payments (settlement_id);
ALTER TABLE larsasub_mollie_connected_accounts ADD settlements_synced_at TIMESTAMP NULL AFTER disconnected_at,
    ADD settlements_sync_error VARCHAR(64) NULL AFTER settlements_synced_at;

SQLite and PostgreSQL (no AFTER):

ALTER TABLE larsasub_recurring_charges ADD COLUMN settlement_id VARCHAR(32) NULL;
ALTER TABLE larsasub_single_payments   ADD COLUMN settlement_id VARCHAR(32) NULL;
CREATE INDEX rc_settlement_idx ON larsasub_recurring_charges (settlement_id);
CREATE INDEX sp_settlement_idx ON larsasub_single_payments (settlement_id);
ALTER TABLE larsasub_mollie_connected_accounts ADD COLUMN settlements_synced_at TIMESTAMP NULL;
ALTER TABLE larsasub_mollie_connected_accounts ADD COLUMN settlements_sync_error VARCHAR(64) NULL;

If you published the migrations, re-publish them with --force first, or use the statements above.

Connect: add settlements.read, and let tenants re-authorise

If you published config/larsasub.php, add 'settlements.read' to connect.scopes — the boot warns until you do. Mollie grants scopes at authorisation, so every tenant connected before this release has to go through the connect flow once more; until then the sync records scope_missing on their account (shown by larsasub:doctor) and leaves their connection alone. After a re-authorisation, SettlementService::syncTenant($tenant) syncs them straight away.

The flow is the route connect.authorize_route names — by default larsasub.mollie.connect.authorize, the same one as the first connect. It is safe on a connected tenant: with the same Mollie organization the account is updated in place (profile, customers and mandates kept, the refusal code cleared); another organization is refused and the existing connection stays.

What a sync outcome means

larsasub:sync-settlements prints an outcome per account that did not run to the end, SettlementSyncResult::$skippedReason returns it for an account that was skipped (a pass that failed along the way sets $error instead), and it is stored as settlements_sync_error on a connected account (shown by larsasub:doctor) unless it is a skip by design. The values (Enums\SettlementSyncOutcome, backed values are contract):

outcome what it means and what to do
scope_missing Mollie answered 403: the tenant authorised before settlements.read was requested. Their connection and payments are untouched; the tenant re-authorises once through the connect flow. On the platform account: the organization token lacks settlements.read or payments.read.
unauthorized Mollie answered 401: the credential itself was rejected, not a scope. Check the connection (the tenant's payments are likely failing too), then re-authorise. On the platform account: the token is wrong or revoked.
provider_error Any other failure — another API error, the network, a connection that is gone, or one or more payouts whose payments, captures or website-profiles list failed (the others were still synced; the failed ones are retried next run). A connected account whose token lacks profiles.read fails every payout this way until the tenant re-authorises. See the log.
owner_conflict A payout this account reads is already recorded for another account. The row is left as it is and none of its payments is attached through this account; the log names the settlement. Either two connected accounts reach the same Mollie organization — not a supported shape; disconnect one — or the platform's own organization is connected as a tenant account while connect.platform_organization_id is unset (the platform pass, which runs first, records its payouts as the platform's, and the tenant account then conflicts on every run: set connect.platform_organization_id, nothing else), or the id was set only after that organization had been synced as a tenant account. Those rows are the platform's: set their tenant_type and tenant_id to null, then run larsasub:sync-settlements --resync with a --days that reaches them. The package never moves a row to another owner itself.
tenant_context The tenant row is gone, or connect.tenant_context failed for it. The account is skipped until that is repaired. Since 0.29 also: the hook threw while leaving the tenant context before the platform pass or before a connected account that is the platform's own organization (not stored for the platform pass — see the log).
platform_owned Not stored. The account is the platform's own organization and settlements.sync_platform is on: the platform pass syncs its payouts. Nothing to do.
testmode Not stored. MOLLIE_KEY is a test key and Mollie has no test payouts, so every account is skipped. Nothing to do.

A new config block

A published config also lacks the new settlements block and the two scheduling.sync_settlements_* keys; larsasub:doctor lists them as missing until you add them. The defaults are carried in code, so nothing breaks meanwhile. To sync the platform's own Mollie account — the only account in simple mode — set LARSASUB_SETTLEMENTS_SYNC_PLATFORM=true and an organization access token with settlements.read and payments.read in LARSASUB_SETTLEMENTS_PLATFORM_TOKEN, plus the profile in LARSASUB_SETTLEMENTS_PLATFORM_PROFILE_ID: Mollie refuses an API key on the Settlements API. See Settlements.

Model logs: one language, and matched by key

Every line the package writes to a record's history is now translated and written in larsasub.locales.log. Nothing to migrate; three things to check:

A published lang/vendor/larsasub/<locale>/<file>.php keeps working: Laravel merges it over the package file per key, so the new log group comes from the package until you publish again. Status texts in columns (activated_status, attention_message) stay English on purpose.

A published config/larsasub.php also lacks the new locales.log key; larsasub:doctor lists it as missing until you add 'log' => env('LARSASUB_LOG_LOCALE'), to the locales block. Nothing breaks meanwhile — the language is app.locale — but LARSASUB_LOG_LOCALE is not read until the key is there.

Two code-level changes, if they apply to you:

0.27.1

One optional contract is added, one reserved column lands on the two payment tables, and one failure goes away: a route-based recipient (Notification::route('mail', …), as the sales funnel makes for address recipients) no longer fails the class-form DatabaseChannel::class or the broadcast channel in notification_channels — both are dropped for that recipient, on every send path, as the database alias always was (broadcast stays for a recipient you gave a broadcast route). And one default changes: an owner without Notifiable now receives its user mail at billable_email instead of nobody — see below.

A column was added in place: refresh your development database

larsasub_recurring_charges and larsasub_single_payments gain provider_payment_status_raw (varchar 32, nullable) in their existing create migrations, per the pre-1.0 rule above. Nothing writes it yet, and it is not fillable; the models cast it, so larsasub:doctor reports it missing on a database migrated from an earlier checkout. Either migrate:fresh, or add it by hand (substitute your table_prefix). MySQL/MariaDB:

ALTER TABLE larsasub_recurring_charges ADD provider_payment_status_raw VARCHAR(32) NULL AFTER provider_payment_status;
ALTER TABLE larsasub_single_payments   ADD provider_payment_status_raw VARCHAR(32) NULL AFTER provider_payment_status;

SQLite and PostgreSQL (no AFTER; column order does not matter to the package):

ALTER TABLE larsasub_recurring_charges ADD COLUMN provider_payment_status_raw VARCHAR(32) NULL;
ALTER TABLE larsasub_single_payments   ADD COLUMN provider_payment_status_raw VARCHAR(32) NULL;

There are no production installs with rows yet; that is the point of landing it now. If you published the migrations (vendor:publish --tag=larsasub-migrations), your copies of the two create migrations are the old ones and migrate:fresh will not add the column: re-publish them with --force first, or use the ALTERs above.

Owners without Notifiable now receive mail at billable_email

If your billable owner is a Team, Company or Tenant model that does not use Laravel's Notifiable and does not override getNotifiableUsers(), it received no user mail before this release. It now receives every user notification at its billable_email. That is almost certainly what you wanted; if it is not, override getNotifiableUsers() (return collect() for nobody, or the people who should get it). Owners that use Notifiable, and hosts that override the method, see no change.

Drain the queue before deploying (as PRODUCTION-CHECKLIST.md already asks): the delivery identity of a route-based recipient — a notifications.sales address, or an owner mailed at billable_email — is computed differently from this release on, so a delivery job that was interrupted mid-list before the deploy and retried after it does not recognise the address it already served. One duplicate mail per such recipient, at most; model recipients are unaffected.

Two consequences of that default. getNotifiableUsers() may now return an Illuminate\Notifications\AnonymousNotifiable instead of a model for such an owner, so host code that calls it directly and reads keys or attributes off the entries (->pluck('email'), a typed closure) has to allow for that. And the database and broadcast channels are dropped for that recipient; a custom channel of yours that needs a model recipient is not, and fails per send — keep it out of the channel list for notifications such an owner receives.

Optional: route one mail type to another recipient

If one of the user mails belongs to someone other than getNotifiableUsers() returns — typically the invoice mail to an invoice address — implement Contracts\ResolvesNotificationRecipients on your billable owner (its one method is getNotifiableUsersFor(Notification $notification): Collection). See Notifications → Recipients. Owners that do not implement it keep their current recipients — including an owner that already has a method of that name for its own purposes, which is left alone.

0.27.0

On a stock install, nothing here needs action. Charge descriptions change shape for charges created from here on, one bugfix lands in the bundled dashboards, one row is added to them, six mail subjects change wording, one gains an upcoming variant, the tenant admin's mail button gets its own label (a published layout keeps the old one until re-published — larsasub:doctor says so in Connect mode), two notifications are added (off), the payment-method mails gain a row, and two things are clarified; nothing changes about what is collected, when, or from whom.

Connect: the tenant admin's button label lives in the layout

Tenant admins now get "Go to :appName" (their own name) under company mails instead of "Go to admin"; the platform copy is unchanged. Nothing to do on a stock install. If you published emails/layout.blade.php, your copy keeps the platform label for both audiences until you port the branch or re-publish the file alone (php artisan vendor:publish --tag=larsasub-email-layout --force); larsasub:doctor reports it while Connect is enabled. The one line, inside the @if($admin ?? false) branch:

-…border-radius: 6px;">{{ __('larsasub::notifications.layout.go-to-admin') }}</a>
+…border-radius: 6px;">@if(($adminAudience ?? 'platform') === 'tenant' && ($adminEnvironmentName ?? '') !== ''){{ __('larsasub::notifications.layout.go-to-tenant-admin', ['appName' => $adminEnvironmentName]) }}@else{{ __('larsasub::notifications.layout.go-to-admin') }}@endif</a>

Port it as shown, guard included: a tenant without a name keeps the platform label on purpose, because "Go to " over a link into the tenant's environment is worse. If you read AdminUrlResolver::currentAudience() in your published layout to get this effect, switch to $adminAudience: the class is @internal and the variable is the supported seam.

Two new notification keys, both off

notifications.user.subscription_started and notifications.company.subscription_started (env LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_STARTED) mail when a subscription activated with a start date ahead reaches it. A published config without the keys reads off — the default is carried in the listener, not only in the config file — so nothing starts arriving on upgrade. Add the keys and set them to true when you want the mails; if you mail on BillableSubscriptionStartedEvent yourself, keep yours or switch, not both.

The payment-method mails changed shape

payment-method-added, -removed and -unusable gained a For row (user.payment-method-*.for) naming the billable, and their titles and intros no longer say "your account". The sentences come from the package's translations, so a published copy of the three views (emails/mandate/user/) renders the new wording too — it only lacks the For row until you re-publish or port the @if(!empty($billableName)) block. A translation override that set title or intro keeps its own sentence, and may now use :billable.

The default charge description is the plan name, and only new charges get it

{plan name} - {m-Y} became {plan name}. The month was a period claim in frozen text and it was wrong for every subscription whose period is not a calendar month — any anniversary day other than the 1st, and any interval beyond one month. The period itself is RecurringCharge::servicePeriod(), which the receipt and the invoice lines already print.

The column is frozen on the row, so existing charges keep their old text and there is no backfill. A development database that wants the new shape throughout needs a reseed; nothing breaks if you leave it.

If your own UI prints $charge->description as the period label, take the period from servicePeriod() instead.

Two subscriber mails drop a row, and one gains one

The paid receipt and the upcoming-collection notice now show their "Description" row only when it differs from the subscription name above it — which, for a package-composed description, it no longer does. A description your own code puts on a charge still shows.

The upcoming-collection notice gains a Period row. If you publish translations (lang/vendor/larsasub), user.subscription-charge-announced.period and .period-value are new. You do not have to add them for the mail to render: the published file is merged over the package's, so a missing key falls back to the package text — in the package's wording, not yours. Add them if you translate the mails yourself. If you publish the views, the row is in emails/recurring-charge/user/subscription-charge-announced.blade.php and the notification passes it as $period (['start' => 'Y-m-d', 'end' => 'Y-m-d'], or null when the charge covers no service period).

servicePeriod() on a list: eager-load billableSubscription

0.26.0 told you to replace your own service-period calculation with $charge->servicePeriod(), and a portal page or an export calls that per row. Without an amount_breakdown — the common charge, since the snapshot is only written when a cost line was billed — the method needs the subscription, so one query per row:

RecurringCharge::query()->with('billableSubscription')->get();

The method now loads the relation explicitly rather than reading it bare, so it no longer throws under Model::preventLazyLoading(). That was a list-only failure and worth knowing if you hit it: Laravel arms that guard per model instance and only when the hydration held more than one row, so a single charge lazy-loaded quietly while a page of them threw.

The bundled subscriber dashboards had exactly this defect: RecurringChargeResource reads the subscription per row for the failed-first-payment flag, while both controllers eager-loaded only the payment method. On a strict-mode host that was a 500 on the payments dashboard as soon as a subscriber had a failed charge. Both now eager-load it.

The dashboards show the period a charge covers

RecurringChargeResource gained a period key (['start' => 'Y-m-d', 'end' => 'Y-m-d'], or null for a charge covering none), and the payment detail dialog and the operator charge detail render it. If you publish translations, billing_history.detail_period and tenant_admin.detail_period are new — the components fall back to the English literal, so a missing key is cosmetic. If you build your own frontend from the resource, the key is simply there.

notifications.user_app_url is not a Connect key

The comment said "It exists for Connect" and "on a single-tenant host there is nothing to set here", which skips the host it helps most: multi-tenant without Connect — the platform billing its tenants, each tenant on its own hostname, config('app.url') pointing at the marketing site. Nothing about the key was ever gated on Connect; the wording was. Set it whenever a subscriber's environment is not config('app.url'). No behaviour changed.

The user_app_url invokable is only called with a Billable

Documented as __invoke(Billable $billable): ?string, and that is now also what the code guarantees. If you use HasEmailViewData in your own notification and hand it something else (an owner model — resolveTenant() supports that), your invokable used to get a TypeError that the resolver caught and logged, leaving the button on config('app.url') for no visible reason. It is not called at all in that case now, with the same result and no log line. A static URL still applies, because it needs no context.

Six mail subjects change wording, and one gains an upcoming variant

Nothing to do unless you match on subject text or overrode it:

Mail Was Is
user.subscription-activated.subject Welcome to :appName - Your subscription is active Your subscription is active
user.subscription-activated.subject-upcoming Welcome to :appName - Your subscription starts on :date Your subscription starts on :date
company.single-payment-paid.subject Single payment received Payment received
company.single-payment-failed.subject Single payment failed Payment failed
company.single-payment-chargeback.subject Single payment chargeback Chargeback received
company.single-payment-refund.subject Single payment refunded Refund processed
company.subscription-activated.subject New subscription activated — always unchanged, but a start date still ahead when the mail renders now sends .subject-upcoming: "New subscription confirmed, starts on :date" — whatever the activation source

The four company titles and intros lose the word the same way. Your own lang/vendor/larsasub override still wins where it sets a key.

If you published either activation view — emails/billable-subscription/{user,company}/subscription-activated.blade.php — your copy predates two things the notification now supplies: the intro-paid-upcoming branch (a paid activation with a future start: the payment claim without "is now active") and $startDateFormatted, the one formatted start date that subject, title, intro and the start-date row all print. The company copy also still prints the "activated" title for a future start; the subject is composed by the notification and changes regardless. Re-publish both views (vendor:publish --tag=larsasub-views --force, or copy the two files), or carry the branches over by hand. If you turned notifications.company.subscription_activated off to work around that subject, you can turn it back on.

0.26.0

On a stock install, nothing here needs action. Two mails gain a row, five gain the customer's name, and one new config key ships unset; nothing changes about what is collected, when, or from whom.

notifications.user_app_url: unset changes nothing, published layouts do

The new key decides where the "go to :appName" button under every user mail points. Left unset it is config('app.url') — exactly what that button did before — so there is nothing to do unless you want it.

If you have published emails/layout.blade.php, setting the key does nothing until you port one line. The button there reads $appUrl; the value arrives in a new $userAppUrl variable, deliberately, so that a published layout does not also drag the footer attribution onto a tenant's host. Nothing errors and nothing logs — larsasub:doctor is what tells you:

-<a href="{{ $appUrl }}" target="_blank" style="display: inline-block; padding: 12px 24px; …
+<a href="{{ $userAppUrl ?? $appUrl }}" target="_blank" style="display: inline-block; padding: 12px 24px; …

That is the app button, inside the @if(!($hideDefaultButton ?? false) …) block — not the $action['url'] button above it, and not the footer link (<a class="email-link" href="{{ $appUrl }}">), which stays on $appUrl because it names the platform. Or re-publish the file alone with php artisan vendor:publish --tag=larsasub-email-layout --force; that tag touches only this one view.

If you published the views or the translations

Seven views changed and both language files gained keys. A published copy keeps what you published, so the rows below simply do not appear until you port them:

View What is new
emails/recurring-charge/user/payment-paid.blade.php A period row, from $period['start'] / $period['end']
emails/single-payment/company/{paid,failed,refund,chargeback}.blade.php A customer row, from $billableName
emails/billable-subscription/company/subscription-cancelled.blade.php A customer row and a plan price row
emails/billable-subscription/company/subscription-changed.blade.php The customer row now reads $billableName instead of walking the relation itself

New translation keys, in nl and en alike: user.payment-paid.period, user.payment-paid.period-value, company.single-payment-{paid,failed,refund,chargeback}.billable, company.subscription-cancelled.{billable,price,price-value}.

A published lang/vendor/larsasub directory keeps its own file, so those keys are missing there until you add them. That is not a rendering problem — Laravel merges the published file over the package's, so a missing key falls back to the package text rather than the raw key — but it is a wording problem if you translate the mails yourself. larsasub:doctor does not check translation files; the package's own NotificationLabelCoverageTest holds nl and en to the same key set, which is the guarantee you inherit only by publishing fresh.

RecurringCharge::servicePeriod() is the answer to "which period"

If your app computes a service period for a charge itself — for a portal page, an export or your own mail — replace that with $charge->servicePeriod() (['start' => 'Y-m-d', 'end' => 'Y-m-d'], or null when the charge covers none). It is what the invoice lines and the payment receipt both use now. The two shapes worth knowing: with an amount_breakdown snapshot it returns the outer bound across the rows, so a mid-period cost line does not shrink it; without one it returns the period containing the planned date, which is not the same as planned date + one interval for a catch-up charge.

0.25.0

On a stock install, nothing here needs action. Some of it you will see anyway — the mail title grows a step, invoices print the country name instead of the code — and the new notification keys are a decision rather than a migration. The changelog is the complete list; this page is only the parts that ask something of you.

If you published something, read the rest. Five sections depend on it, and they are spread across four publish tags: larsasub-views, larsasub-email-layout, larsasub-assets and larsasub-vue-components.

Mail headings: the title grows to 17px — visible in every mail

This is the one change here you will see without looking for it. The mail title (.email-title) goes from 15px/700 to 17px/700 in all 41 notification views, and the section heading above a detail table (.email-section-title) from 17px/600 to 15px/700.

The defect was the order. A section heading of 17px/600 sat under a title of 15px/700, so the subheading above the table was the biggest text on the page. That predates 0.24.0; it only became visible in it, because once both headings took the theme's heading colour, size was the only hierarchy left.

Shrinking the section heading looks like the cheaper fix — two views instead of 41 — and it does not work. The table's header cells carry no font-size at all, so they inherit the body's 15px at weight 600; a heading below that is less prominent than the table it introduces. There is no room between 15/600 and 15/700 for a third level, so the title is what had to move.

The scale is now monotonic on both axes:

Level Size / weight
mail title 17px / 700
section heading 15px / 700
table header cell 15px / 600 (inherited size)
body text 15px / 400

0.24.0 deliberately held the title at its old rendering so that release would be visually neutral. That is no longer true — this one is not. Nothing breaks and no action is required, but if you review your transactional mail, expect every title to be a step larger.

If you published the views, your copies keep their own sizes and are unaffected — which also means they keep the inverted order. Port both numbers, or re-publish:

php artisan vendor:publish --tag=larsasub-views --existing

Two new notification keys, both off — the recurring payment receipt

notifications.user.payment_paid and notifications.company.recurring_charge_paid are new and both default to false, so nothing changes on upgrade. They are listed here because turning one on is a decision, and because one of them fixes something you may already have configured.

'notifications' => [
    'user'    => ['payment_paid' => true],
    'company' => ['recurring_charge_paid' => true],   // env: LARSASUB_NOTIFY_COMPANY_CHARGE_PAID
],

Until now "paid" was the only outcome of a recurring collection nobody was told about — failed, cancelled, expired, refunded and charged-back all mail, and so does the announcement that a collection is coming. The successful case had a mail in month one only, as a side effect of the activation.

They ship off because on a continuous monthly subscription this is a mail every month, forever: service for a B2B host billing companies, noise for a consumer app billing individuals, and not something an upgrade should decide for either.

If you set notifications.attachments = 'invoice' in 0.24.0, you want user.payment_paid on. That key attaches the invoice to the mail about the record it belongs to, and the only subscriber mail that had one was the activation — so you have been delivering month one's invoice and nothing after it. The receipt is the mail each later period's invoice rides on.

Two cases stay silent whatever you set: the first payment, because the activation mail already reports that period and carries the same invoice, and a mandate-setup verification, because €0,01 that comes straight back is not a payment to acknowledge.

If you published config/larsasub.php, add the keys by hand or re-publish — an absent key reads as null, which is falsy, so the mails stay off either way. larsasub:doctor lists absent keys.

The mail layout has its own publish tag

php artisan vendor:publish --tag=larsasub-email-layout --force

One file: emails/layout.blade.php. Until now the only way to reach it was larsasub-views, which copies the whole view tree — so customising the mail chrome left you owning 41 notification views frozen at the version you published them, and "re-publish the layout" was advice that destroyed work. It is now a safe instruction.

If you already published the whole tree to get at the layout, this is the moment to delete the notification views you never meant to own and keep only the layout.

larsasub:doctor reports a mail layout that is too old for the theme keys

email_theme.primary_ink and light/dark.accent_text are read in the layout and nowhere else. A host holding a pre-0.24 copy of that file could set them and get nothing — no error, no warning, no log line, and a button label still hard-coded to white. Doctor now warns, naming the role that is dead:

WARN  Your published emails/layout.blade.php predates these email_theme
      roles, so setting them does nothing:
      ⇂ primary_ink (the button label)

It matches on the view variable rather than on a version, so a layout you hand-merged the new lines into is accepted. It cannot see a tenant theme set through getEmailTheme() — the same rule applies there.

Invoices now print the country name, not the code — including on published views

No action required unless you want codes back, in which case:

LARSASUB_INVOICE_COUNTRY_DISPLAY=code

invoicing.country_display defaults to 'name' instead of 'code', so the seller and customer blocks on invoices and credit notes read Netherlands (Nederland on a document rendered in nl) where they read NL.

Two things make this worth reading rather than skipping.

First, it reaches you even if you published the invoice views. The rule for published views — you keep your copy and do not get our changes — does not hold here: the behaviour is in CountryHelpers::display(), and your published blade calls that same helper. If you published the views specifically to control how the address block looks, this is the one change in that area you will see anyway.

Second, if you already worked around the missing Dutch names by defining larsasub::countries.{CODE} in your own app, you keep your wording. A host translation still wins from the package's; nothing is overwritten.

The stored data is untouched. customer_country and seller_country still hold ISO 3166-1 alpha-2 codes, every VAT decision still runs on codes, and an unknown code still prints as the code — an invoice never loses information because a name is missing.

Republish the built assets — every bundle changed

Action required if you published the built assets (--tag=larsasub-assets, which copies dist/ into public/vendor/larsasub). The profile dashboard's country picker moved from a list hardcoded in the component to one the server sends, and all eleven bundles are rebuilt — not just the profile one. Two reasons: the combined standalone-app.umd.js carries that component too, and every bundle embeds the frontend translation file, which gained a key. Your published copy is the one the browser loads:

php artisan vendor:publish --tag=larsasub-assets --force

Skip it and the picker keeps running the old 47-country list — the page will look like nothing changed, which is the confusing part.

A published profile-dashboard.blade.php needs one new payload key

Action required only if you published the dashboard views (--tag=larsasub-views). The country picker on the profile page no longer carries its own list of countries — it receives one from the controller, which is how it can offer all 252 codes the endpoint accepts instead of the 47 it used to hardcode. Your published copy does not pass it, so the dropdown would come up empty.

Add the one line to your profile-dashboard.blade.php payload:

'payload' => [
    'profileCode' => $profileCode ?? '',
    'invoiceDetails' => $invoiceDetails ?? [],
    'countryOptions' => $countryOptions ?? [],   // <-- add this
    'updateUrl' => $updateUrl ?? '',
],

Until you do, the picker says the country list is unavailable rather than showing an empty dropdown that reads as "no countries exist". Nothing else on the page is affected, and a country that is already stored still shows — as its code, since the name it would be rendered with is exactly what did not arrive.

InvoiceDetailsForm.vue takes a new prop

Action required if you publish the Vue components (--tag=larsasub-vue-components) and mount InvoiceDetailsForm yourself. It now takes countryOptions — Record<string, string>, ISO 3166-1 alpha-2 to name — instead of carrying a list of its own:

countryOptions: data.countryOptions || {},   // from the page payload

Build it server-side with CountryHelpers::options($locale), which is the same list the update endpoint (larsasub.my-profile.update) validates against. Anything narrower makes the form refuse countries the endpoint would have accepted, which is the bug this replaced.

0.24.0

Writing start_date on a subscription now throws

Action required if any of your code does $subscription->update(['start_date' => …]) — including an admin panel that offers it as an editable field. Replace it with:

app(BillableSubscriptionService::class)->moveStartDate($subscription, $newDate);

The reason is a money bug, not tidiness. A line added before a subscription starts is clamped up to its start_date, so it sits exactly on it — that clamp is what says "this line runs from day one", and it makes the two dates one invariant. Moving start_date alone broke it in both directions, silently. On a €9,95 plan with two €4,95 modules (€24,02 due at 21% VAT): brought forward it collected €12,04, pushed back €36,00. No warning, no log, and the invoice looked right.

moveStartDate() moves the lines with the date in one transaction on a subscription that has not been billed yet — which is the real use case, an operator letting an upcoming subscription start earlier. It leaves a line that deliberately starts later alone.

On a subscription that has ANY charge it refuses, and so does a direct write. That is the harder half of this change: billing periods are derived from start_date and nothing records which days a charge covered, so moving it can hand the planner a window that overlaps days already paid. Measured on a subscription starting Jan 15 with a paid charge covering Feb 15 – Mar 14: the start date pushed to Feb 20 planned a second charge on Feb 25, the base price again. No bookkeeping fixes that, so the date stays put — change the cost lines instead (endLine() the wrong ones, addLine() them again from the right date).

"Any charge" is wider than "was paid", and that is deliberate rather than sloppy: a failed charge is superseded rather than replaced, so its planned_on stays in the table and keeps answering the planner's idempotency question. Deciding which of several rows for one period still counts is exactly the reasoning that has no stable ground here. The case the method exists for — an upcoming subscription on a trial or a deferred mandate, which the planner has not reached yet — has no charge rows at all.

Two limits worth knowing:

The rest

Everything below is additive and defaults to today's behaviour, so an untouched install needs nothing. Two things need action, and only if you publish package files:

The mail title is an <h2> now — check what you published

Every notification title moved from <p><strong>…</strong> to <h2 class="email-title"> with the heading color inline. Size, weight and spacing are unchanged, so an unmodified install looks the same as before.

If you published the VIEWS, your copies keep the old markup and therefore never pick up the new heading color — nothing errors, the title is simply themed in the package's mails and not in yours. Re-publish or port the change into your copies. Re-publishing needs both flags, or it silently does nothing:

php artisan vendor:publish --tag=larsasub-views --existing

--existing is what does the work: without it vendor:publish skips every file that already exists, and because this tag publishes a directory it prints its "copying" line either way — so the plain command looks like it worked and copied nothing. (--force is the flag for the opposite case, publishing everything including files you never had; next to --existing it changes nothing.)

It overwrites them. That is the point, and it means any customization in those files is gone. Diff or copy them first, then re-apply what you changed:

cp -r resources/views/vendor/LarsaSub resources/views/vendor/LarsaSub.bak

If you published only the LAYOUT, act before you ship — this one is visible. Your emails/layout.blade.php predates the new classes, so its dark block has rules for p, h3 and strong and none for the title. The new views set the title's light-mode color inline (#0F172A by default), and an inherited color does not override an inline one: in dark mode the title lands on the dark card at 1,22:1 — gone. It was a <p><strong> before, which your dark rules did cover. Either re-publish the layout with the command above, or paste these two lines into your @media (prefers-color-scheme: dark) block:

.email-text .email-title { color: #F1F5F9 !important; }
.email-text .email-section-title { color: #F1F5F9 !important; }

(#F1F5F9 is the shipped dark.text — substitute your own dark.text, or dark.heading if you set that.)

The two CSS lines are not the whole delta, and the rest is silent. The new primary_ink and accent_text keys below are read in the LAYOUT and nowhere else — button label, wordmark, footer link are layout chrome, so unlike heading (which the views write inline) they never reach a host holding an older copy of that file. Set them on an old layout and nothing happens: no error, no warning, no log line.

If you take the paste branch rather than re-publishing, there are nine sites, three per variable. Each one replaces a value that used to be hardcoded or that used $primary:

Variable Sites in the layout Replaces
$primaryInk the action button, the "go to admin" button, the "go to app" button — all three are <a … style="… color: …"> a hardcoded #ffffff
$accentText the light-mode .email-text a { … } rule, the <td class="email-header"> wordmark, the <a class="email-link"> footer link $primary
$dkAccentText inside @media (prefers-color-scheme: dark): the .email-header rule, the .email-text a rule, the .email-link rule $primary

The safest way to apply it is a diff rather than a transcription — the shipped file is the authority:

diff resources/views/vendor/LarsaSub/emails/layout.blade.php \
     vendor/peters-development/larsasub/resources/views/emails/layout.blade.php

From the release after 0.24.0, larsasub:doctor reports this for you: it warns when you have set one of those keys and your published layout does not read the matching variable. That release also adds a publish tag for this one file, so re-publishing stops costing you the notification views.

And check what that tag actually gave you, which is probably more than you intended. It is all-or-nothing: it publishes the entire resources/views tree to views/vendor/LarsaSub, and on 0.24.0 there is no tag for the mail layout alone (there is one from the next release — see the 0.25.0 section above). So:

ls resources/views/vendor/LarsaSub/emails/

If that shows more than layout.blade.php, you have 41 notification views frozen at whatever version you published them — amount rows, VAT-notice labels and the monthly-amount-rows partial included, all of which have changed since. Delete the ones you never meant to customize and keep only the files you actually edited — and if what you keep is the layout, apply the two dark-mode lines above to it.

This is also the argument for the new keys below: until now, changing one color meant freezing the content of 41 mails.

Three new email_theme keys, all defaulting to current behaviour

primary is now the button surface only. Two new roles came out of it — primary_ink (top-level: what is printed on that surface, null = derived) and light.accent_text / dark.accent_text (the accent as text: wordmark and links, per mode). Plus light.heading / dark.heading for the title and section-heading color, null = that mode's text.

Nothing changes until you set one — including if you published config/larsasub.php before this release: a key that is absent reads as null, which is the default. Add the keys to your published config when you want to use them.

That reassurance is about a published CONFIG. For a published LAYOUT the truth is the opposite: setting primary_ink or accent_text changes nothing either, because your copy of the layout is what reads them. See the four lines in the previous section.

The one behavioural difference in the default install: a button label on a light accent now flips to dark ink where it used to stay white. On the package default #4077F4 it stays white (4,07:1, above the 3:1 threshold) — the flip only affects hosts whose accent made the white label unreadable in the first place.

A brand that needs all three:

'email_theme' => [
    'primary' => '#fe9900',                   // button surface: the real brand color
    'light'  => ['accent_text' => '#a85500'], // readable as text on the white card
    'dark'   => ['accent_text' => '#fe9900'], // readable as text on the dark card
],

Setting only primary_ink is not enough for a light accent: it fixes the button label and leaves the footer link at 2,15:1. See Notifications.

A new config key: notifications.attachments. Defaults to false, which is exactly today's behaviour — nothing is attached to anything. Set it to 'invoice' to have the package's own invoice PDF ride along with the mail about the payment it belongs to, or to an invokable class name to decide per notification. If you switched a notifications.user.* key off purely to send your own mail with a document attached, this is the key that lets you turn it back on; see Attachments. 'invoice' needs barryvdh/laravel-dompdf — ConfigValidator warns at boot if it is missing, and mails go out without the file rather than failing.

A new config key: notifications.tenant_model_url. Republish the config, or add the key by hand next to tenant_admin_url; leaving it out keeps today's behaviour exactly. It decides where the "view payment" / "view subscription" deep link in a company mail sends a TENANT's admins. If you have been reaching for AdminUrlResolver::resolveUsing(..., AdminAudience::Tenant) to answer that, move to this key: that class is @internal and PUBLIC-API.md now says so explicitly. A registered resolver still works and still runs — the config key simply wins over it.

Company mails gained rows. The four single-payment mails now print the payment's description; failed, refund and chargeback also print reference and amount; the subscriber's failed copy gained all three; and the company activation mail now names the customer and states the contract terms. If you published resources/views/vendor/LarsaSub/emails/, your copies do not have these rows — diff against the package's versions and pull them in. The three new company.single-payment-{failed,chargeback,refund}.description keys are in both shipped locales; a published lang directory needs them added.

Six translation keys were removed: the two subscription-changed plan-id keys, price from both subscription-changed blocks, and remaining-charges / no-remaining-charges from the company subscription-cancelled block (the subscriber block keeps its own). Nothing in the package rendered any of them — each has a sibling key the view actually uses — so this only matters if you hand-wrote a published view against them.

0.23.0

Two things need action rather than reading: one schema change (larsasub_subscription_cost_lines.valued_from) with a one-off command to run after migrating (larsasub:backfill-valued-from), and a new larsasub:execute-charges pass that writes at the payment provider on its own initiative and is on by default — it can create a mandate, not only adopt one. Both below.

The rest: one new DeactivationReason case and four new refusals around a failed or abandoned first payment; one route path changes and the zero-amount first-payment returns gain parameters on three paths that did not send them; one new company mail is on by default; an introduction price of €0 is now refused on update as well; free plans get a mandate choice (subscription.free_plan_mandate, default = today's behaviour) and under 'refuse' a free plan is no longer declined to a subscriber who already holds a usable mandate — the first-charge quote reports that refusal instead of naming an amount for it; the payment-provider contract gains one concrete method; the invoice's net/VAT split of a multi-line charge moves by cents and its lines re-sort to byte order; mandate adoption accepts strictly more mandates; the reconciliation run report gains a fifth counter (skipped) for an adoption the locked row refused; two boot-time config checks changed — a new warning when the reconciliation window exceeds Mollie's idempotency-key retention, and an unusable scheduling.*_frequency no longer takes the whole scheduler registration down with it; and the lock-order contract's host-side half is now written down.

valued_from on cost lines — run the migration, then the backfill

A cost line now records the first day of the stretch its billed_through closes that money was actually valued for. The column closes a money bug: an opening charge clamps its window to its own period start and writes the days before it off, while billed_through still moved to the period END — so endLine() refunded across that gap and minted cost_line_adjustment credit that had never been collected (€24,44 measured on a €7,50 module after a four-month gap between an abandoned checkout and its paid retry).

The migration is edited in place (pre-1.0 policy), so an existing development database has nothing pending and migrate reports success while the next cost-line write dies on Unknown column. Either migrate:fresh, or:

ALTER TABLE larsasub_subscription_cost_lines ADD COLUMN valued_from date NULL;

larsasub:doctor reports the missing column. Hosts that read cost-line rows directly should use valuedFrom() rather than the raw attribute: the raw null means "nothing valued yet" on a fresh line and "predates the column" on an old one.

Existing rows are NOT all unaffected — the clamped ones are the exception

Existing rows keep null, which SubscriptionCostLine::valuedFrom() reads as starts_on. For every line whose charges never clamped that is exactly what they would have written, so nothing changes. For a line that a clamped opening charge already marked, starts_on is precisely the wrong lower bound — the one this fix exists to replace. Such a line keeps refunding across the gap until it is repaired. The fix prevents new cases; it repairs none.

Run the repair once, after migrating:

php artisan larsasub:backfill-valued-from --dry-run   # inspect first
php artisan larsasub:backfill-valued-from

It reconstructs the window from the charge history, which is possible because the clamped billed_from is stored VERBATIM in the charge's amount_breakdown entry, next to previous_billed_through — the same two facts markLinesBilled() decides on. The command walks that chain backwards and writes only a date the snapshots prove. It is idempotent, makes no provider calls, and touches no owner or tenant models (so it needs no tenant context).

Scope: open lines (ends_on IS NULL) that carry a billed_through. An ended line can never refund again, so its window is inert.

What it cannot repair, and how to recognize it

The command never guesses: a valued_from written one day too late turns a visible over-refund into a silent under-refund — money the customer is owed, with nothing on record to find it by. Anything it cannot prove is left exactly as it is, keeping the old behaviour, and is listed by line id, key and reason at the end of the run. That list is the recognizer, not the column: a line left null because its window provably starts at starts_on looks identical to a line left null because nothing could be proven.

The reasons (Enums\ValuedFromBlocker, backed values are contract):

reason what the row looks like
no-snapshot No charge of that subscription has a cost-line entry for the key that closes on the line's billed_through. Typically amount_breakdown is null or hand-edited, the charge row was hard-deleted, or the charge predates 0.22.0 (before which a first payment priced itself and stored no snapshot). Do not rule a charge out for being a first payment as such: since 0.22.0 it composes like any other and carries a breakdown whenever cost lines exist.
chain-broken The owning entry continues an earlier stretch whose charge is no longer on record, so where the stretch STARTS is unknown.
snapshot-conflict Two or more charges close on that day and disagree about the window they opened. Which one left the mark is undecidable.
snapshot-predates-field The entry records no previous_billed_through and opened later than starts_on. Almost always harmless: that field shipped in the same change as the clamp, so a charge without it provably never clamped. Reported rather than assumed.
chain-loop The walk met the same window twice — a snapshot set that contradicts itself. A backstop in practice: a cycle needs at least one entry that is incoherent on its own, and that is refused a step earlier as incoherent-snapshot.
incoherent-snapshot One entry contradicts itself: billed_from after billed_until, or a previous_billed_through at or after billed_from (a charge cannot have found the line at or past the day it started billing). Read that entry's three dates and correct the wrong one, then re-run. Only the affected line is skipped — unlike unreadable-snapshot, nothing here is hidden from the other lines of that subscription.
snapshot-contradicts-line One entry is consistent with itself but cannot be about this line: it opens before the line's starts_on, or records the line as having stood at a day before it existed. Neither is producible, so put the entry and the row side by side — and consider that the LINE may be the wrong one rather than the entry (an older entry for the same key, left behind after the line it belonged to was replaced). Only the affected line is skipped.
unreadable-snapshot At least one charge of that subscription stores an amount_breakdown that is not a list of entries (a scalar or hand-edited JSON document). Repairable: fix the document and re-run. It blocks every candidate line of that subscription rather than skipping the bad charge — a dropped entry could have been the second candidate that made the answer a conflict, or the predecessor that carries the stretch further back.
error The charge history of that subscription could not be read at all; the run logs why, skips those lines and continues.

To repair one by hand, open the subscription's charges ordered by planned_on, find the cost-line entry in each amount_breakdown whose key matches, and read off the earliest billed_from of the unbroken run of windows ending at the line's billed_through; set valued_from to that day. If the history cannot answer it, the safe value is the one that is already there — null, i.e. starts_on, which over-refunds rather than under-refunds.

An invoice's subtotal now follows its lines

InvoiceService used to derive subtotal from the invoice TOTAL (amount / 1.21) while its lines were de-grossed one by one, and the difference was absorbed by the last line. On prices_include_vat = false at 21% that difference is ≈0,17 cent per cost line and grows linearly: a €9,95 plan with 50 modules of €1,99 collected €132,54 and invoiced it as €109,54 + €23,00, with the last module line reading €2,08 instead of €1,99 — while quoteFirstCharge() named the correct €109,45 + €23,09 on the checkout screen. The subtotal is now the sum of the lines and vat_amount is total − subtotal — provided those lines sum to the collected amount, which a well-formed charge always does; a stored breakdown that does not keeps the old derivation, so the difference stays visible on a line.

What this means for you:

Two items deserve reading before you deploy rather than after: larsasub:execute-charges gained a pass that registers — and on the NL flow creates — missing mandates at the provider, mails the subscriber about it, and spends the run limit a third time (new key scheduling.recover_mandates_within_days, no off switch); and the order of amount_breakdown, and therefore of invoice lines, is now byte order instead of the database collation.

An introduction price of €0 can no longer be saved

Subscription's minimum-price rule for the introduction price lived in the creating hook only — updating ran just validateBeforeSave(), which did not contain it. So the shape could come into existence in exactly one way: by creating a plan with a real introduction price and editing it to 0 afterwards. It is not a supported product. A priced plan with a €0 introduction price produces a first charge of €0, which settles locally without a checkout, so no payment mandate is ever established and period 2 has nothing to collect with.

The rule now lives in validateBeforeSave(), which both hooks run, and the bundled tenant plan form (TenantSubscriptionRequest) rejects the same shape as a 422 instead of leaning on the model's exception.

What this means for you:

Free plans: subscription.free_plan_mandate

New config key, default 'activate', which is what every earlier release did: a plan with price_amount = 0 is activated without a mandate. No action needed unless you want one of the other two.

All three values ask "is there already something to collect with?" through ONE call — PaymentProviderService::billableHasUsableMandate(), a tenant-scoped query on the mandate rows — and never Billable::usableMandates(), which is tenant-blind and turns an unreachable provider registry into "no mandate". When the question cannot be answered (a Connect registration whose tenant cannot be established, a provider that cannot be resolved) every case reads it as not proven: 'mandate' starts a verification it may not need, 'refuse' declines a signup it might have allowed. Both are cheap next to the alternative, which is a free subscription that silently has nothing to collect with.

Custom payment providers: three concrete methods were added to the contract this release. All three ship a working default, so nothing outside this package breaks — but if your own implementation already declares a method under one of these names with a different signature, that is a fatal at class load, not a test failure. Check the names before you upgrade:

Otherwise free_plan_mandate is conservative on Connect installs (it will refuse, or start a verification, where a mandate actually exists). Simple-mode installs are answered exactly.

An unrecognized value is a boot failure, not a fallback.

Related and unconditional: createMandateSetupPayment() now resumes a verification that is still RUNNING by redirecting to its stored checkout URL, instead of creating a second €0,01 charge and a second mandate. "Running" means the setup status may still complete AND the charge is a live attempt with an active provider status and a stored checkout URL — a finished verification never resumes, so a customer whose mandate was removed afterwards can still add a new payment method. If you call it yourself, a 303 to a checkout URL may now be a resumed attempt rather than a fresh one, and on that path the $paymentMethod you pass is ignored (the open checkout belongs to the instrument the customer picked first); the returned response type is unchanged.

It can now also THROW where it previously started a second verification. The guard and the charge are one atomic claim (a transaction opening on the Billable row lock), and a concurrent caller can only ever find that claim before the checkout URL exists — so a claim with no provider response that was taken in the last 60 seconds is refused with the new Exceptions\MandateSetupInProgressException (a LarsaSubException, so an existing catch still catches it) carrying the translated larsasub::method.setup-already-started, rather than collecting a second €0,01. The bundled dashboard answers it with HTTP 409 and the message; if you call the method yourself, treat it as "a verification is starting, reload" and not as a failure — catching the subclass is how you tell that apart from a provider error without matching on a translated string.

And past that window it no longer starts a second verification either. A claim with no provider response is RETRIED on its own charge row, so the provider sees the first attempt's idempotency key. Mollie answers that key one of two ways — it hands back the payment the first attempt made, or, when that attempt never reached it, it creates the payment now — and both leave exactly one payment instead of two. Three things follow for callers: createMandateSetupPayment() may now return a 303 to a checkout that belongs to an earlier attempt of the same verification (and, when that payment no longer has a checkout page, to the redirect.mandate_setup destination instead of the provider); no new RecurringCharge is created on that path — code counting mandate-setup charges to count attempts will see one where it used to see several; and the $paymentMethod you pass is ignored on that path too, because the second outcome above would otherwise create a payment for an instrument the claim does not describe. The whole request is rebuilt from the claim; a differing method is written to the charge's log. Mollie keeps idempotency keys for one hour; past that a retry does create a new payment, and closing that gap is a scheduling condition on your app, not something the package guarantees: reconciliation has to resolve the claim inside that hour, and the shipped execute_charges_frequency of daily does not. The worst case is reconcile_unresolved_after_minutes (default 30) plus your scheduling interval; keep that sum under 60 — everyFifteenMinutes does, hourly and daily do not.

Abandoned checkouts stay as deactivated rows

larsasub:cleanup-unused-billable-subscriptions no longer soft-deletes never-activated subscriptions. It deactivates them with the new DeactivationReason::FirstPaymentAbandoned, and it leaves rows that a terminal webhook already deactivated completely untouched.

What changes for you:

The retry is refused for a plan that is no longer on sale

retryFailedFirstPayment() collects the price frozen on the subscription row. It now throws a BillableSubscriptionServiceException when the plan behind that row is deleted or no longer active, and BillableSubscriptionResource omits retry_first_payment_url in that case. terminate_url is unchanged, so there is always an exit. If you build your own UI on the resource, handle the absent key (it was always conditional).

The retry is refused next to another subscription

retryFailedFirstPayment() now also throws when the billable already has a non-deactivated, non-cancelled subscription — the same "already has a subscription" rule createSubscription() applies. Without it a stale tab could reactivate the old row next to the new one: two active subscriptions on one billable, both collecting monthly and both producing invoices. A deactivated sibling (another abandoned checkout) does not block anything.

The retry is refused once the contract period is over

retryFailedFirstPayment() now also throws a BillableSubscriptionServiceException when the row carries an end_date that is in the past — a fixed-term plan whose term ran out while the checkout sat abandoned. createSubscription() has always answered that case at its own entry point; the retry path only looked unbounded because the sweeper used to soft-delete these rows and route-model binding then lost them. Since this release the row stays (see "Abandoned checkouts stay as deactivated rows"), so the guard has to be stated rather than inherited from a deletion.

BillableSubscriptionResource omits retry_first_payment_url for such a row, so this is a second reason the key can be absent — handle it the same way as the withdrawn-plan case. terminate_url stays open, as with every other refusal: there is always an exit. A row without an end_date (a continuous plan) is never refused on this ground.

MySubscriptionController::retryFirstPayment() redirects instead of throwing

The route caught nothing, so every refusal reached the subscriber as an error page. It now redirects to larsasub.my-subscription.view with the translated message in session('error'), exactly like createSubscription(). If you posted to this route from your own code and relied on the exception, read the flash instead.

A late retry bills one period, not the months in between

The cost-line rollback on an abandoned checkout empties billed_through, which means "never billed" — so a retry months later valued every day since the line's starts_on. The charge that OPENS a subscription now clamps every cost-line window to its own period start. The monthly planner is unchanged (a line added mid-period is still caught up on the next charge). If you recorded a first charge that billed more than one period of a module, it was this.

Importing a running subscriber: keep paidThrough / billedThrough to the period immediately before the import. The clamp applies to the opening charge whatever opened it, so an import whose stated position is more than one period old loses the gap in two ways: the intervening months are written off (logged, not collected), and the stretch you declared as already paid for falls outside the refund window — ending that line later refunds only from the clamped start. Measured: a line stating billedThrough: '2026-02-28' whose opening charge lands in May refunds €7,50 where €18,87 was prepaid. As long as billed_through + 1 is on or after the opening charge's period start — the ordinary import, where you state the period you just collected — nothing changes. See Migrating subscribers in.

A failed retry cleans up after itself

When the provider call behind a retry fails and the controller hands the claim back, the retry charge is now soft-deleted (credit returned, cost lines rolled back) instead of being left standing. It had no route out otherwise — supersession needs a failed provider status and a claim — so the next execute-charges run collected it. A charge whose claim is still standing is untouched: that one belongs to reconciliation.

terminateFailedSubscription() can refuse

It now throws when a claimed payment attempt is still live (claimed, not paid, no terminal provider status) — the same guard the cleanup sweeper has. Deleting there meant money collected with no subscription to book it against. Callers that assumed it always succeeds should surface the message.

The mandate-setup redirect route gained an optional segment

/larsasub/redirects/mandate-setup is now /larsasub/redirects/mandate-setup/{charge?}, and the package puts the €0,01 verification charge in it when it creates the payment.

The segment is optional and must stay optional. That URL is embedded in the payment at Mollie before the customer leaves, so every mandate verification still open when you deploy carries the old, segment-less URL; a required segment would give exactly those customers a 404 on return from their bank. Nothing to do — but if you build your own route for larsasub.redirect.mandate_setup and read the query string, note that it now also receives charge next to mandate_setup=pending. And as with first_payment below: a host route with an optional {charge} segment goes from /return to /return/12, so check anything that matches on the exact path.

The zero-amount first-payment returns now send their route parameters

Three call sites in MollieService (the two zero-amount branches and the mandate-reuse return) read larsasub.redirect.first_payment directly and redirected with no route parameters, while RedirectController has always added charge and subscription. One config key, two different URLs — and a host route declaring {charge} as a required segment raised a UrlGenerationException on a free-plan signup. All four now produce the same URL.

What changes for you:

redirect.* now also accepts an invokable class

A value shaped like a namespaced PHP class name (App\Billing\Foo — identifier segments separated by backslashes) is read as a class name, resolved from the container and called as __invoke(mixed $context, ?string $origin = null): ?string. Anything else stays a route name, including values that hold a backslash but cannot be a class name (App\Foo-Bar).

The one case where existing configuration breaks. Laravel accepts any string as a route name, class-shaped ones included, so that shape is now reserved for invokables and a route named that way is no longer reachable through a redirect.* key. If you configured one, the package either boot-fails (ConfigValidator: no such class) or calls the class that happens to carry that name instead of your route. Rename the route, or point the key at a route name that is not class-shaped. Nothing else changes: route names of every conventional shape keep resolving exactly as before.

Two things to know before adopting it:

A class-shaped value that is not an existing invokable class now fails at boot (ConfigValidator). A route name that does not exist does not fail at boot — routes are not loaded yet — but php artisan larsasub:doctor reports it, and at runtime the resolver falls back instead of throwing.

One new company mail is ON by default

No schema change, no config key removed. Hosts that upgrade get one new default-on company notification: mandate_registration_failed. It fires on exactly one shape — a first payment that was paid while registering the payment mandate behind it threw — which leaves money collected, the subscription active, an invoice raised, and nothing to collect the next period with. It is deliberately a second mail: the charge is also flagged with setNeedsAttention(), which keeps dispatching PaymentNeedsAttentionEvent and its generic payment_needs_attention mail for the same charge. The generic one says a payment needs looking at; this one names a customer whose next collection is already lost — different urgency, different action.

HandlesMollieWebhooks is stated as internal

PaymentProviders\Mollie\Traits\HandlesMollieWebhooks is now marked @internal and named as internal in docs/PUBLIC-API.md. Nothing about it was ever meant as a host surface — every method is protected and every caller is one of the package's own Mollie webhook controllers — but the Mollie\Traits namespace was promised wholesale next to IsMollieBillableModel, which is and stays public. If you did reach into the trait: resolveMandateIdForCharge() was split into storedMandateIdForCharge() (the mandate id Mollie put on the payment) and findAdoptableMandate() (returns the Mandate, not an id).

LarsaSubFixtures::activatedSubscription() takes the real plan snapshot

Test-surface only, but it can change what your existing tests assert. The fixture re-copied the plan field by field and had fallen behind BillableSubscription::snapshotFromPlan() — the definition createSubscription() actually writes — by billing_interval_months. It now spreads that snapshot instead. Precedence is unchanged (snapshot → the fixture's lifecycle defaults → your $overrides), and the subscription_id override still names the snapshot source.

Two things to look for in your own suite:

larsasub:execute-charges now repairs missing mandates — and can create them

This is the one change in the release that writes at the payment provider on its own initiative, and it is on by default. Every larsasub:execute-charges run opens with a new pass, PaymentReconciliationService::reconcileMissingMandates(), before the reconciliation passes and before both execution loops.

What it repairs: a first payment that was paid while the mandate registration behind it threw. Money in, subscription active, invoice raised — and nothing to collect period 2 with. The charge was flagged needs_attention and stayed there until an operator noticed; from now on the next run retries the registration, links the mandate to the subscription, lifts the flag, and frees the month-2 collections that stranded on it. A mandate recovered this way is usable by the same run.

Four things to take in before you deploy:

PaymentReconciliationService::recoverMandateFor($charge) is public for a one-charge repair by hand (docs/PUBLIC-API.md): every precondition is re-checked inside it, it returns false with a logged reason when it refuses — and it throws on a failure worth retrying, so catch it if you call it from a request.

Full description: A paid first payment without a mandate.

Mandate adoption now accepts strictly more mandates

Two widenings on the checkout paths, so a mandate that already exists at Mollie is adopted instead of duplicated: pending now counts as adoptable next to valid (the package's own adopted mandates are recorded as pending, so excluding them made the lookup blind to exactly those), and the lookup walks every page of the customer's mandate list up to 250 rather than only the first 50.

What to look for: if you monitor or alert on the PaymentMethodControllerException "No mandate ID found" from the card/PayPal first-payment path, expect that signal to drop or disappear. It was firing for customers whose matching mandate merely sat on page two. Nothing about the guardrails widened — an account/card match is still required where a wrong adoption would matter, and a mandate the customer removed here is still never brought back except on a proven identity during their own checkout.

amount_breakdown (and therefore invoice lines) sorts byte-order

The cost-line rows used to be ordered by orderBy('key') — the database's collation, which under MySQL's default is case- and accent-insensitive. The charge and the new first-charge quote share one composition, and a quote works on unsaved models with no database to sort them, so that order moved into PHP as sortBy(..., SORT_STRING): plain byte order, strcmp.

No amount changes. What changes is the order of amount_breakdown and therefore of the invoice lines built from it, and only for hosts whose cost-line keys mix case or accents: byte order puts every uppercase letter before every lowercase one, so Module:B now precedes module:a where MySQL placed them the other way round. Keys in one consistent case — nearly everyone — are unaffected. If your invoice layout or a test asserts a line order, check it once against a mixed-case key set. Invoices already issued do not move — they are immutable snapshots. Byte order was chosen because it is the one answer that is identical on every host's database, which is what a snapshot property computed by two different callers needs.

The lock-order contract has a host-side half

Not new behaviour — new documentation of an obligation that was always there, stated here because it is the kind of thing nobody reads a PUBLIC-API page for until it deadlocks. If you wrap a LarsaSub call in your own DB::transaction():

Both are stated at Wrapping a LarsaSub call in your own transaction.

New boot warning: the reconciliation window, on the shipped defaults

ConfigValidator now adds up scheduling.reconcile_unresolved_after_minutes and the scheduling.execute_charges_frequency interval, and warns while the sum reaches 60 — the minutes Mollie keeps an idempotency key, and therefore the window in which releasing a stranded claim is still safe. larsasub:doctor prints the same sum as a Reconciliation window line.

It fires on the shipped defaults (30 + daily = 1470). Nothing about the behaviour changed — this is the same condition the mandate-setup retry section above and docs/SCHEDULING.md already state — but it is newly loud, so two practical notes:

An unusable scheduling.*_frequency no longer kills the registration

ScheduledCommandsServiceProvider handed the raw config value to a string parameter. Values that coerce (true, 30) fell through to the daily fallback; null and an array did not — they were a TypeError thrown during scheduler registration, which took every command registered after it with it. A published config/larsasub.php produces exactly that null for a present-but-empty env variable (LARSASUB_EXECUTE_CHARGES_FREQUENCY=), so a host could lose its billing schedule to an empty line in .env.

Every unusable value — absent, null, an array, a bool, a number, or a misspelled name — now resolves through one helper (SchedulerHelpers::normalizeFrequency()) before registration, and resolves to that command's own documented default rather than to daily. Two consequences:

0.22.0

No schema change. One new config key, one breaking signature for custom providers, and four behaviour changes on the first-payment path — all of them about the same thing: the checkout is now a real first payment for period 1.

The first charge collects the active cost lines

RecurringChargeService::generateFirstSubscriptionPayment() used to price itself (plan-or-introduction price plus a VAT conversion). It now runs through SubscriptionCostService::calculateChargeAmount() — the same calculation the monthly planner uses — so the first charge carries the active cost lines, freezes an amount_breakdown snapshot and advances each line's billed_through in the same transaction.

Without cost lines nothing moves: same amount, same VAT treatment, no breakdown. With them, money that used to arrive on the month-2 charge (as a catch-up) now arrives on the first one, invoice 1 itemises the modules and invoice 2 stops showing them twice. This applies to every caller — the bundled subscribe flow, the plan change, the retry, and a headless host calling the method directly.

Two consequences for hosts that mutate cost lines:

beforeFirstCharge — new hook, new config key

subscription.before_first_charge (default null = off) names an invokable class run right after a subscription is created and before its first charge is priced: __invoke(BillableSubscription $subscription, ?BillableSubscription $previous = null): void. Code that calls the provider entry points itself can pass a ?Closure instead (PaymentProviderService::createSubscription(), createSubscriptionWithMandate(), BillableSubscriptionService::changeSubscription()); an explicit closure wins. It fires on those three paths only — not on createDeferredMandateSubscription(), where the caller adds its own lines after creation. The hook runs inside the creating transaction, so keep it cheap and DB-only; a throw rolls the whole creation back.

Adoption contract. Configuring the invokable replaces an existing "re-add the modules" listener on BillableSubscriptionChangedEvent: the invokable fires on change too, so running both means an overlap-guard exception (same keys) or double-billed modules (different keys). Gate the old listener on blank(config('larsasub.subscription.before_first_charge')) while migrating — null, '' and a whitespace-only value all mean "no hook" to the resolver, so a strict === null would disable your listener for an empty env variable while nothing replaced it — or gate the hook on $previous === null to keep it to subscribes.

A class name that does not exist or is not invokable is a boot fatal (ConfigValidator, reported by larsasub:doctor) — the same treatment billable.owner_resolver and connect.tenant_context get. So is a class whose __invoke() provably cannot take the hook's arguments (a first parameter typed as something a BillableSubscription is not, or a non-nullable second parameter): that one passes every other check and then throws a TypeError inside the creating transaction, so every subscriber gets a rolled-back subscription. The check only judges declared, single, named types — untyped parameters, mixed/object, union and intersection types are all left alone, and so is an invokable that declares fewer parameters than the package passes. A blank value (null, '', whitespace) means "off" and is never fatal, in the validator and the resolver alike.

Breaking for custom payment providers

PaymentProviderService::createSubscription() and createSubscriptionWithMandate() are abstract and gained a trailing ?Closure $beforeFirstCharge = null. An implementation without it fails at class load. Declaring it is enough to load; resolving it through BeforeFirstChargeHook::resolve() and passing the result to BillableSubscriptionService::createSubscription(beforeFirstCharge: …) is what makes the config fallback work for hosts on your provider. See PUBLIC-API.

A future start_date now throws

generateFirstSubscriptionPayment() refuses a subscription whose start_date is still in the future. The charge's breakdown states which days it covers and paidThrough() reads it, so billing "today" for a subscription that starts next month would hand out access to a period nobody entered. No package caller reaches this (checkout and change start today, the retry historically), but the undocumented headless pattern "collect now, start later" did work before — use createDeferredMandateSubscription() for that shape.

'0 months' introduction duration now charges the full price

The old first-payment branch only asked whether introduction_duration was non-empty and not 'none', so '0 months' (or any unparseable value) handed out the introduction price on the first charge — while every later charge already billed the full price. The shared calculation resolves it through currentEffectivePrice(), which counts months covered: zero of them means the introduction is already over. A correction, not a regression; check your plan catalogue if you have such a value.

isCharged: false on a priced plan now yields a €0 first charge

createSubscription(isCharged: false) on a plan with a price used to produce one full-price first charge on a subscription the planner then skips forever. It now produces a €0 charge, settled locally — consistent with the executor, which already settles a charge on a non-chargeable subscription at €0. Free plans (price 0, always is_charged = false) are unchanged. Open cost lines on such a subscription are never valued and are logged as a warning: the rows stay and are inert.

0.21.0

Three new user mails are ON by default

No schema change, no config key removed. Hosts that upgrade get three new default-on user notifications: "payment method added" (on MandateMarkedUsableEvent), "payment method removed" (on the new MandateRemovedEvent from MandateService::removeMandate()) and "payment method no longer usable" (on MandateMarkedUnusableEvent for a genuine provider-side invalidation — the weekly validation, a 404 during refresh, a connect-reset; the in-transaction step of a removal is skipped via the new $partOfRemoval flag, so removals announce once). Banking practice wants these on, so they default to true — disable them with notifications.user.payment_method_added / .payment_method_removed / .payment_method_unusable if your host sends its own confirmations. Note the connect-reset consequence: larsasub:connect-reset marks every mandate of the tenant unusable, so with the toggle on, every affected subscriber is mailed to set up a new method — which is exactly the re-authorization nudge that reset requires, but switch the toggle off first if you want to communicate it yourself. Translations ship in en/nl; hosts with published translations (--tag=larsasub-translations) re-publish or add the notifications.user.payment-method-added / .payment-method-removed / .payment-method-unusable groups to their copies, or the mails render the raw keys. One narrow compile-time edge: ProviderMandateModel::markUnusable() gained an optional bool $partOfRemoval = false parameter — a host subclass that OVERRIDES that method with the old signature fails to load until the override adds the parameter (overriding model methods is not a supported extension point, but the failure is loud, so it is named here).

0.20.1

A published monthly-amount-rows partial misses the new VAT-split rows

No schema change, no config key change. Under a Gross collection notice (prices_include_vat = false with a positive rate) the shared mail partial emails/partials/monthly-amount-rows.blade.php now prints a subtotal-excl-VAT row and a VAT row above the inclusive total (and the same pair for the introduction total, when the introduction conversion itself moves the amount). The new view-data keys from MonthlyAmountSummary::toViewData() — netPeriodTotal, vatAmount, introNetPeriodTotal, introVatAmount — are additive; nothing existing was renamed, so an outdated published copy keeps rendering exactly what it did before, without the new rows and without any error. If you published the package views (--tag=larsasub-views), re-publish the partial or port the two @if(($netPeriodTotal ?? null) !== null) / @if(($introNetPeriodTotal ?? null) !== null) blocks into your copy. Hosts that only override emails/layout.blade.php are unaffected.

0.20.0

No schema change, no config key change. One behavior change, and it is narrow.

createDeferredMandateSubscription() rounds a start date with a time component UP

start_date is a calendar day and it is the first day that is billed — the planner works in whole days and collects from 00:00. A caller passing a timestamp mid-day ("the trial ends the 15th at 14:30") meant "covered through that moment", and flooring it to its own day collected a day the customer had not finished: one early collection per month, hitting exactly whoever cancelled on the morning of their last trial day. Such a date now rounds up to the next calendar day, with a log line naming what happened.

Unchanged: an explicit midnight date, a plain date string, and the import shape (paidThrough given), where $startDate is the historic contract day that anchors the anniversary and a round-up would shift every imported customer's billing day.

What you need to do. Nothing if you pass a date ('2026-09-15', CarbonImmutable::today(), or omit the argument). One call shape changes meaning: a literal now() used to mean "today" and now means "tomorrow" — pass CarbonImmutable::today() instead. Rounding up gives away at most hours; it never collects early, which is why this direction was chosen.

Documentation catch-up for two 0.19.0 changes

Two public-API changes shipped in 0.19.0 without an upgrade note and are now documented under 0.19.0 below, where a host upgrading past that release will look: the MonthlyAmountSummary property/view-key renames (which fail silently in a published mail view) and the SubscriptionPeriodCalculator::calculateMonthlyPaymentCount() rename. If you went straight from 0.18.0 to 0.20.0, read the 0.19.0 section in full — nothing in it is superseded by this release.

0.19.0

Schema: two columns were added to existing create migrations

billable_subscriptions gained first_charge_on (nullable date) and billing_interval_months (default 1); subscriptions gained billing_interval_months. Per the pre-1.0 policy these were edited into the create migrations, so an existing development database has nothing pending while the columns are missing. Run migrate:fresh, or add them by hand — larsasub:doctor reports them until you do.

The planner catches up an unbilled running period

Previously a subscription whose running billing period held no charge was only caught up once the in-month anniversary day had passed; an older gap was silently skipped. The planner now bills the running period on the day it notices it (never further back than the running period, and first_charge_on still holds collection back). What you need to do: nothing for normal data. If your database contains active, chargeable subscriptions with a deliberately unbilled running period, set first_charge_on on them before updating — that is the supported way to say "do not collect yet".

Cancellation on interval plans rounds down to a period boundary

New behavior for plans with billing_interval_months > 1: a notice period that spills past a period boundary no longer extends into (and charges) one more full prepaid period — the subscription ends with the already-paid period. Monthly plans are arithmetically unchanged. Configure notice none on quarterly/yearly plans; the paid period itself is the protection.

MonthlyAmountSummary renamed two properties — and a published mail view fails silently

Support\MonthlyAmountSummary is public API, and its toViewData() keys are the contract a published mail Blade receives. Both renames are per-billing-period figures now, so the old names had become wrong words:

Before After
->monthlyTotal / $monthlyTotal ->periodTotal / $periodTotal
->introMonthlyTotal / $introMonthlyTotal ->introPeriodTotal / $introPeriodTotal

plus two new view variables, $perPeriod and $perMonth — ready-made captions ("per month", "per year"), because a Blade must never build a translation key from a number.

What you need to do. If you published the mail views (--tag=larsasub-views), republish emails/partials/monthly-amount-rows.blade.php and re-apply your customizations, or rename the variables in your copy. This one does not error: the partial guards with @if(($periodTotal ?? null) !== null), so an old copy simply stops printing the total row — the amount the mail exists to state. If you read the object directly (headless hosts sending their own copies), rename the two property reads; an undefined-property read yields null, not an exception.

SubscriptionPeriodCalculator::calculateMonthlyPaymentCount() was renamed

It is calculatePaymentCount(CarbonImmutable $startDate, CarbonImmutable $endDate, int $intervalMonths = 1) now — it counts one payment per billing period, not per month. Omitting the third argument reproduces the old monthly behavior exactly. The class is date math the package uses internally; if you called it, this is the only change.

Republish translations if you published them

The mail keys price-value, price-after-intro, total-per-month* (now total-per-period*) and new-monthly-total* (now new-period-total*) take a :per_period placeholder, and several plan-form strings now say "billing period" instead of "month". Rendered output for monthly plans is unchanged, but published overrides (--tag=larsasub-translations) keep the old keys — republish and re-apply your customizations. The same applies to published dashboard assets (--tag=larsasub-assets): republish so the components that render the new billing_period_label are live.

0.18.0

Connect mode: tenant admins no longer get the platform's admin link

Company mails carry a "go to admin" button, and in Connect mode they reach two audiences. Until now both got the URL registered by your admin-panel package — the platform's. Tenant admins now get no admin button unless you say where they administer:

// config/larsasub.php
'notifications' => [
    // an absolute URL, or the class name of an invokable receiving the tenant
    'tenant_admin_url' => env('LARSASUB_NOTIFICATIONS_TENANT_ADMIN_URL'),
],

What you need to do: usually nothing — the first registered bundled tenant dashboard is used automatically. One case needs a decision: if your tenants live on their own domains and your tenant_context hook does not force a per-tenant root URL, that fallback resolves against app.url and would hand a tenant admin the right path on the platform's host. Set this key there, in its invokable form. (If your hook does force the root — the usual reason being that links in tenant mails must land on the tenant's domain — the fallback follows it and is already right. The platform copy is built from app.url regardless, so it cannot inherit that root.) Leaving the key unset is otherwise safe: the button is omitted rather than pointing somewhere wrong.

Two more cases worth checking:

Note the name: a LARSASUB_STANDALONE_TENANT_ADMIN_URL appeared in older docs but was never read by any config. The working key is the one above.

If you published the views (--tag=larsasub-views), your emails/layout.blade.php predates this and would render href="" when there is no admin URL. The notification also sets $hideDefaultButton for that case, which every published layout already honours, so the button disappears correctly either way — but re-publishing the layout keeps the two guards in sync.

Admin-panel packages (larsasub-nova, larsasub-filament) need no change: AdminUrlResolver::resolveUsing() and baseUrlUsing() gained an optional audience argument that defaults to Platform. Note that a closure building its URL with url() inherits whatever root URL is active at send time — on a host that forces a per-tenant root, build platform URLs from config('app.url') instead.

Published translations: eight new keys per company subscription mail

The company copies of the activation and change mails now quote the amount that is collected, through the same partial the user mails use. That adds eight keys per mail — total-per-month, total-per-month-gross, total-per-month-reverse-charge, total-per-month-net and the four total-during-introduction… variants — under company.subscription-activated and company.subscription-changed.

Nothing breaks if you skip this. Laravel merges a published lang/vendor/larsasub/{locale}/notifications.php over the package's own file with array_replace_recursive, so keys you do not have fall through to the shipped wording. Add them only if you want to phrase them differently; copy the shipped user.subscription-activated block, the wording is identical.

If you published the views (--tag=larsasub-views), your copies of emails/billable-subscription/company/subscription-{activated,changed}.blade.php keep printing the plan price until you add the @include('LarsaSub::emails.partials.monthly-amount-rows', ['keyPrefix' => …]) line; the notification now passes the view data for it either way.

0.17.0

The cost-line notice moved to a new event — drain your queue on deploy

SubscriptionCostChangedNotification's constructor changed from ($subscription, $costLine, $changeType, $monthlyTotal) to ($subscription, Support\CostLineChangeSet $changes), and the package's own listener now binds to the new SubscriptionCostChangedEvent instead of the three per-line events. A notification of this type already on the queue when you deploy will fail to render.

What you need to do: nothing if your queue is empty at deploy time (these jobs are short-lived). Otherwise let the queue drain before deploying, as usual for a notification payload change.

If you listen to the cost-line events yourself: the three per-line events (SubscriptionCostLineAddedEvent, …ChangedEvent, …EndedEvent) are unchanged and still fire, one per line — keep using them for audit. But do not notify a subscriber from them: a reconcileLines() call fires one per line, and each carries the monthly total at that moment, so a three-module change sends three mails naming two amounts nobody will be charged. Notify from SubscriptionCostChangedEvent, which fires once per act with the settled total, and check $event->changes->shouldAnnounce() to skip a subscription's opening composition. See docs/cost-lines.md.

Fewer cost-line notices go out, by design

Two categories are no longer announced to the subscriber:

If your app depends on a mail for those, send it yourself from SubscriptionCostChangedEvent — it fires in both cases, only the package's notice is withheld.

The activation mail now shows the monthly total, not the plan price

SubscriptionActivatedNotification lists active cost lines and a monthly total when there are any (plus the total during an introduction period, if there is one). Subscriptions without cost lines render exactly as before. If you publish your own copy of emails/billable-subscription/user/subscription-activated.blade.php, copy the new rows over — otherwise your override keeps stating the plan price alone, which is not what a subscriber with add-ons pays. Two translation keys were added: user.subscription-activated.total-per-month and .total-during-introduction.

The user.subscription-cost-changed block changed too: item is gone, and intro-mixed, change-added, change-changed, change-ended, change-from and was are new. Published translation overrides need the same edit.

The subscription mails now state the amount that is actually debited

Which mails. All four that quote a monthly amount — activation, renewal announcement, renewed, plan changed — plus the "your monthly amount changed" notice. They printed the plan's catalogue price, which is not what gets collected as soon as the subscription carries cost lines, the install prices exclusive of VAT, or an introduction period is running. The renewal announcement is the one to look at first: it announces a future collection, so its amount is a pre-notification. The plan-changed mail keeps its old-vs-new comparison of listed prices and states the debit below it.

An introduction period was wrong by the whole discount. The charge bills currentEffectivePrice() while these mails summed the catalogue price, so a €30 plan on a €10 introduction with a €7,50 module announced €37,50 against a €17,50 collection. The mails now print the introduction total and the amount that follows it.

If you publish mail views, five Blades changed and one is new. Four render their totals through the new emails/partials/monthly-amount-rows.blade.php:

@include('LarsaSub::emails.partials.monthly-amount-rows', ['keyPrefix' => 'user.subscription-renewal-announced'])

The keyPrefix argument is required — it names the translation namespace the partial builds its keys from, and it differs per mail (user.subscription-activated, …-renewal-announced, …-renewed, …-changed). subscription-cost-changed is the fifth: it carries the same rows inline, under its own new-monthly-total key, because its total comes frozen out of the change set rather than being resolved at render.

An override that keeps the old rows keeps printing the catalogue price — no error, but the renewal announcement then keeps announcing an amount that will not be collected.

The VAT label on those totals — check this if prices_include_vat is false

The amounts these mails print per row are the listed prices; their total is what the charge actually collects, and VAT moves the two apart:

invoicing Customer Listed Debited
prices_include_vat = false pays VAT €30,00 €36,30
prices_include_vat = true zero-rated (reverse charge / non-EU) €30,00 €24,79

Two things changed as a result. The total row is now labelled, and it appears even when the subscription has no cost lines — previously there was no total row at all in that case, so on a VAT-exclusive install the activation mail announced €30,00 and the collection took €36,30 with nothing explaining the difference. The three labels:

prices_include_vat Customer Label
false pays VAT "Total per month (incl. 21% VAT)"
true EU B2B reverse charge "Total per month (VAT reverse-charged)"
true outside the EU "Total per month (0% VAT)"

The combinations not in that table produce no label and no extra row, because there is nothing to explain: with exclusive prices a zero-rated customer is charged the listed amount as-is, and with inclusive prices a domestic customer is too.

Reverse charge is deliberately not "no VAT": the customer owes it in their own country, which is what the invoice for the same subscription has always printed.

If invoicing.enabled is false, or prices_include_vat is true and you have no zero-rated customers: nothing changes. That is the default install. A plan whose effective rate is 0% gets no label and no extra row either — there is no rate to name.

If you publish your own copies, all five need an edit:

--tag=larsasub-translations published to a directory Laravel does not read

Unrelated to the above, and older: the tag published to resources/lang/vendor/larsasub, while the translation loader reads overrides from langPath()/vendor/larsasub. Those are the same directory only on a pre-Laravel-9 skeleton — a modern app keeps lang/ in the project root, and langPath() points at resources/lang only when that directory already exists. So publishing reported success and the override was ignored — but only until the next boot. Publishing CREATES resources/lang, and langPath() points there as soon as it exists, so from the second boot the LarsaSub override worked and the host's own translations in lang/ stopped being read. That second half is the damage, and it is why an override that appears to work is not evidence that you can skip the migration below.

First check which case you are in, because resources/lang is a legitimate language path — Laravel uses it whenever it exists, and plenty of apps keep it on purpose.

For the second case, in this order:

  1. Move resources/lang/vendor/larsasub to lang/vendor/larsasub.
  2. Delete resources/lang — check it holds nothing else of yours first.
  3. Only now is vendor:publish --tag=larsasub-translations meaningful again.

Re-running the publish before step 2 changes nothing: while resources/lang exists, langPath() still points at it, so the new target is the same directory as the old one — and the publish overwrites your edits.

If you use a custom language path (useLangPath() in your bootstrap), none of this applies: publish to app()->langPath('vendor/larsasub'), which is what the tag now targets, and leave your path alone.

SubscriptionCostService is now a container singleton

The batch scope lives on the instance, so app(SubscriptionCostService::class) and an injected SubscriptionCostService must be the same object. If you bound the class yourself, make your binding a singleton too.

The Mollie website profile is now a choice, not a guess — check your columns

larsasub_mollie_connected_accounts gains three columns (mollie_profile_name, mollie_profile_count, profile_selected_at).

migrate will not add them. Before 1.0 the package edits its migrations in place rather than shipping add-column migrations, so an existing database has nothing pending: migrate reports success and the columns stay missing until the first handleCallback() or setProfile() dies on Unknown column. A development database from an earlier checkout needs migrate:fresh, or the three columns added by hand. Run php artisan larsasub:doctor to confirm — it verifies columns, not just tables, and these three are in the model's $fillable, so it sees them.

What changes for a connected tenant. Nothing moves on its own: the profile stored today stays stored. What stops is the silent re-pick — handleCallback() now only chooses a profile when none is stored, so a reconnect can no longer move a running tenant to another brand (and to another set of enabled payment methods). Existing accounts get profile_selected_at = null, which reads as "the package picked this", and profile_count fills in on the next connect or the first listProfiles() call.

Two cases where a reconnect DOES still reselect, because the alternative is a connection that cannot collect: the stored profile no longer exists at Mollie (every payment would fail on a dead profile id), and the previously stored organization was unknown — a first connect without organization info is tolerated, so "the same organization as before" was never proven and a choice made under it cannot be carried over. Both clear profile_selected_at, so the tenant is asked again, and both are written to the account's model log. A profile lookup that simply fails changes nothing at all.

What you may want to build. MollieConnectService::listProfiles() and setProfile() are new, and getStatus() gained profile_id, profile_name, profile_count, profile_selected and profile_choice_pending. If your tenants have more than one website under one Mollie organization, show the active profile and let them change it — see Connect → Choosing the website profile.

MollieClientContext gained a fourth constructor argument (connectedAccount). Only relevant if you construct one yourself: application fees and the testmode flag now key on that flag instead of on the presence of a profile id. They used to disappear silently when a profile lookup had failed — including the testmode flag, which meant LIVE payments on a test install. MollieClientResolver sets it for you.

MollieConnectService::fetchPrimaryProfileId() is replaced by fetchProfiles(), which returns Support\MollieProfile objects.

0.16.0

Run migrate — the Connect OAuth flow has its own table now

larsasub_connect_flow_states is new. The Mollie Connect authorization flow used to keep its state in the cache, on the application's default store; it now keeps it in this table, on larsasub.database.connection like every other LarsaSub table.

Who was affected before this release: hosts whose default cache store is tenant-scoped — 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. The OAuth callback arrives on the redirect URI's origin with no tenant bound, so it could not read what authorize() had written from the tenant's host, and connecting failed with 403 Invalid or expired state parameter. If you worked around this by pointing your default cache store elsewhere, you can undo that now.

What you need to do: run php artisan migrate.

Connect flows in progress do not survive the deploy. The old release kept the state and the completion ticket in the cache; this one reads only the new table, with no fallback and nothing to migrate — the two live for at most 30 minutes, so carrying them over would be more machinery than the window is worth. A tenant who is on Mollie's consent screen while you deploy gets 403 Invalid or expired state parameter on their way back and has to click "Connect Mollie" again. Nothing is lost beyond that: no tokens are issued until complete() runs, so there is no half-connected account to clean up.

This release adds no configuration of its own: no new setting, no prune command and no retention key. A flow is deleted as soon as it completes or is declined; one that is simply abandoned stops being usable after 30 minutes and is removed from the table when the next authorization starts, so an install that has finished connecting its tenants can hold a handful of expired rows until someone connects again.

One EXISTING setting is worth checking if you use Connect, and it is not the same statement as the paragraph above. The origin the callback hands the single-use completion ticket back to comes from larsasub.standalone.domain when that is set; leave it null and it is taken from the request's Host header, which nothing validates unless your application runs Laravel's TrustHosts middleware. A forged header delivers the ticket to a host the attacker chose, which defeats the account-linking protection in complete() — see the Security note in the changelog. Nothing about that changed in this release and no upgrade breaks if you do nothing, but set standalone.domain or enable TrustHosts; from this release on, an unvalidated host is logged at every authorize() so you can see whether it applies to you.

One behavior change worth knowing: a tenant who declines authorization at Mollie now returns to the host they started from. That branch read its return URL from the same flow state, so it had been landing them on app.url instead.

0.15.0

Required: Laravel 12.11 or newer

The constraint moved from ^12.0 to ^12.11|^13.0. Composer enforces it, so an application below 12.11 simply will not receive this release — but the reason matters if you pin versions: Laravel only dispatches NotificationFailed from 12.11 onward, and without that event a notification whose send throws keeps its delivery claim. The retry is then refused as a duplicate and the job reports success with nothing sent. On 12.10 the delivery log would be quietly unsafe rather than loudly broken, which is why this is a hard requirement instead of a recommendation.

Migrations were consolidated

Four migrations were folded into the tables they altered. The columns they added now sit in the create migration itself, and the four files are gone:

Removed Folded into
..._add_fee_columns_to_recurring_charges_table ..._create_recurring_charges_table
..._add_tenant_usable_to_payment_methods ..._create_payment_methods_table
..._add_processed_at_to_single_payment_entities ..._create_single_payment_entities_table
..._add_minimum_days_before_change_to_subscriptions ..._create_subscriptions_table

What you need to do: run php artisan migrate:fresh on a development database. That is the only state this package tests and the only one it guarantees.

The honest detail, because it changes how urgent this is: a database that already ran all four add-migrations has the same schema a fresh one gets — same columns, same types, and migrate will not try to re-run anything. What it does have is four rows in its migrations table naming files that no longer exist. That is harmless for migrate and for running the application; it breaks migrate:rollback, which looks for a file it cannot find.

So: no production data is at risk here, and nothing silently drifts. But a development database whose migration history no longer matches the files is a thing you will trip over eventually, and migrate:fresh costs nothing on a database you can recreate.

From the first 1.0 release onward this stops: schema changes ship as new migrations and old ones are never rewritten or removed.

Coming from 0.13 or earlier: nothing to do

0.14.0 was superseded the day it was tagged. An upgrade that skips it lands past both bugs below and never produced an affected row.

If you did run 0.14.0: two things worth a look

See the changelog for what went wrong. Neither is automatically correctable:

0.14.0

VAT on exclusive prices: what is collected changes

Read this before the first collection after the upgrade if you run invoicing.enabled = true with invoicing.prices_include_vat = false.

With exclusive prices the package used to collect the listed price and invoice that same amount plus VAT: a €9,95 plan with a €4,95 cost line debited €14,90 and produced an €18,03 invoice. Both halves are corrected, and the charge half changes the amount that leaves your customer's account:

Already-issued invoices that state more than was collected are operator territory — credit note plus re-invoice; the package does not rewrite them.

In-place migration: VAT snapshot columns

vat_rate, vat_treatment, vat_reverse_charge and vat_customer on both recurring_charges and single_payments. Development databases: php artisan migrate:fresh. A database with data you want to keep (MySQL syntax; adjust the prefix, drop AFTER on drivers without it):

ALTER TABLE larsasub_recurring_charges
    ADD vat_rate DECIMAL(5,2) NULL AFTER amount_breakdown,
    ADD vat_treatment VARCHAR(16) NOT NULL DEFAULT 'excluded' AFTER vat_rate,
    ADD vat_reverse_charge TINYINT(1) NOT NULL DEFAULT 0 AFTER vat_treatment,
    ADD vat_customer LONGTEXT NULL AFTER vat_reverse_charge;

ALTER TABLE larsasub_single_payments
    ADD vat_rate DECIMAL(5,2) NULL AFTER description,
    ADD vat_treatment VARCHAR(16) NOT NULL DEFAULT 'excluded' AFTER vat_rate,
    ADD vat_reverse_charge TINYINT(1) NOT NULL DEFAULT 0 AFTER vat_treatment,
    ADD vat_customer LONGTEXT NULL AFTER vat_reverse_charge;

vat_customer is LONGTEXT, not JSON, because it is encrypted at rest: it carries the customer's VAT number, and a ciphertext is not valid JSON — a JSON column rejects the write outright. If you already created it as JSON from an earlier revision of this guide, change the type and clear the column (ALTER TABLE ... MODIFY vat_customer LONGTEXT NULL followed by UPDATE ... SET vat_customer = NULL) before running any charge: the snapshot only feeds invoices generated after it was written, so dropping unencrypted legacy values costs nothing that a live profile does not already provide.

No backfill. The four columns record what the charge side decided when the money moved: the effective VAT rate (0.00 = zero-rated), how the VAT relates to the stored amount (excluded, included, or added when the package grossed it up — only added is re-added on a refund, so a later prices_include_vat switch cannot move the credit for days billed under the old mode), and whether the zero rate was a reverse charge — plus vat_customer, the profile fields that decision rested on (country, type, VAT number). The invoice prints those next to the rate: a frozen reverse-charge statement beside a profile that no longer carries a VAT number is a document that cannot justify its own zero rate. Name and address still come from the current record — those describe where the customer is, not what the sale was — the invoice prints that last one as a legal statement, and the profile fields it would otherwise be derived from are the customer's to edit afterwards. Existing rows keep NULL / 0 deliberately — a charge that was collected net before the upgrade and invoiced after it must keep being invoiced exactly as it would have been, or the customer receives a document showing a lower product price than they paid. Every charge planned from 0.14 on carries the snapshot, and the invoice follows it instead of re-reading the plan rate and the (customer-editable) profile fields at invoicing time.

MandateSetupStatus gained two cases

MandateSetupStatus now also returns parked and released next to none | started | failed | confirmed. Two things to check in host code:

Previously parked reported as started (right behavior, no signal) and released collapsed to none (indistinguishable from "never attempted"). Hosts that only branch on confirmed/failed need no change.

IsPayable gained a seventh hook (breaking)

Every class implementing Contracts\IsPayable must add:

use PetersDevelopment\LarsaSub\Support\PaymentChargeback;

public function onPaymentChargebackReversed(SinglePayment $payment, PaymentChargeback $chargeback): void
{
    // Book back exactly what onPaymentChargeback() booked for $chargeback->id.
}

PHP fails at class load without it, so this is a compile-time break, not a silent one. The hook is added directly to the interface rather than as an optional side contract: pre-1.0, one method per implementation is cheaper than permanent API complexity.

Why it exists. A card chargeback is not final — the provider sets a reversal timestamp when the money comes back. That was already stored, but never delivered: the package's once-per-occurrence delivery hangs off an id-diff, and a reversal adds no id. So a host that booked the chargeback as a negative payment line kept that line forever: invoice reopened, customer dunned for money that had been returned. Two matching events ship with it, RecurringChargeChargebackReversedEvent and SinglePaymentChargebackReversedEvent (the charge side has no IsPayable equivalent, so there the event is the whole surface). Neither has a package listener.

Three things to know before you write the body:

Not covered, deliberately: a credit note the package already issued for the chargeback is not clawed back (no debit-note mechanism; operator territory), and the refund-side mirror — a refund going from refunded to failed — is still stored without any delivery.

Detail entries without a provider id are skipped

provider_chargeback_details / provider_refund_details entries that arrive from the provider without an id are no longer merged into the stored snapshot and no longer delivered; a warning is logged instead. They were never usable: the merge keys on that id, so every id-less entry collapsed onto the same key (two of them became one, silently shrinking a set documented to only grow), and the "key your idempotency on the value object's id" contract cannot be honored for an occurrence that has no id.

Id-less entries already stored are kept verbatim and ride along in the snapshot — the raw set stays the raw set — but they are no longer keyed on, so they can no longer swallow each other. Nothing to do unless your own code reads those snapshots and counted on the (impossible) id-less occurrence being delivered.

Mandate removal states its reason; canDeleteMandate() is gone

MandateService::removeMandate() throws for five different reasons and they all shared one empty exception class, so telling them apart meant matching the translated message. Each throw now carries a MandateRemovalBlocker:

use PetersDevelopment\LarsaSub\Enums\MandateRemovalBlocker;
use PetersDevelopment\LarsaSub\Exceptions\MandateServiceException;
use PetersDevelopment\LarsaSub\Service\MandateService;

try {
    app(MandateService::class)->removeMandate($owner, $mandateId, $tenant);
} catch (MandateServiceException $e) {
    match ($e->reason) {
        MandateRemovalBlocker::InFlightCharges => $this->askToWaitForSettlement(),
        MandateRemovalBlocker::LastUsableMandate,
        MandateRemovalBlocker::NoCompatibleReplacement => $this->askToAddAnotherMethod(),
        default => $this->showGenericError($e->getMessage()),
    };
}

$e->reason is nullable (an exception constructed elsewhere carries none) and the message is unchanged, so existing catch blocks keep working. The constructor gained the reason as a fourth, defaulted parameter after $previous; positional construction is unaffected.

Two changes that need action:

Also corrected: removeMandate()'s docblock claimed it throws LarsaSubException on a failed provider revocation. It does not, and never did — that failure is logged, the mandate is marked unusable locally and the local record is deleted anyway. And the order requirement is now stated. Both an active or upcoming subscription and a planned charge count as depending on a mandate — a standalone planned charge blocks removal on its own, without any subscription involved. When another usable mandate can take those dependants over, removal reassigns them and succeeds; when none can, the dependants have to go first (cancel the subscription, let it run out, or let the charge settle). Removing the mandate first is not a shortcut: it throws.

Also fixed: the replacement pool is now tenant-scoped. removeMandate() already accepted a tenant, but the locked mandate query inside the transaction filtered on the billable alone. A billable holding mandates at two tenants could therefore have a subscription of tenant A reassigned to a mandate living in tenant B's Mollie account; switchMandate() validates the billable and usability, not the tenant, so nothing downstream caught it and the next collection failed against an account that does not know the mandate. Where such a cross-tenant replacement used to be picked silently, removal now refuses with last_usable_mandate.

Notifications are delivered by LarsaSub's own queued job

The three funnels (NotificationRecipientResolver::notify(), CompanyNotificationResolver::notify() and ::notifyTenant()) no longer call Notification::send(). They dispatch one job, Jobs\DeliverNotification, which resolves the recipients and sends to each of them — with sendNow(), inside the tenant context it activates itself.

Why. Every LarsaSub notification is ShouldQueue, so Notification::send() handed the work to a second job in which the recipient was re-hydrated from a ModelIdentifier and toMail() re-read the billable's owner. In an app whose owners live in per-tenant databases, both reads happened without tenant context, and silently: a connection name survives serialization and simply resolves to whichever tenant database that connection currently points at. Job middleware cannot repair it (SerializesModels restores in __unserialize(), before the middleware pipeline and before handle()), so the delivery itself had to move. See docs/multi-database-tenancy.md.

What you lose — all of it for LarsaSub's own notifications only, and none of it used by the package:

What you gain, and the one thing you still own. A connect.tenant_context hook now runs on the notification path as well, so recipients, toMail() and the email theme all read inside the right tenant. A throwing hook fails the job so the queue retries it (nothing was sent yet, so no duplicates); a stored tenant that no longer resolves stops quietly instead; and if a company notification exhausts its retries, the job's failed() hook still sends the platform-sales copy, exactly once. Not covered, because it never was: whatever you queue off a LarsaSub event — your own listeners and your own notifications. A LarsaSub notification on Laravel's database channel IS covered: that channel writes through the notifiable's own relation, so the row follows the recipient's connection — the tenant database it belongs to, where before it followed whatever the worker's connection happened to point at. A channel that persists through its own model is a different case and is not covered; see Channels with their own model.

Config warning. connect.tenant_context set while connect.enabled is false now produces a boot warning (and a larsasub:doctor entry). Automatic tenant derivation only happens in Connect mode, so registrations created the ordinary way store no tenant and the hook never fires for them — while every owner read still runs against whatever database the connection points at. It is a warning and not a fatal precisely because it is not absolute: a host that passes the tenant explicitly to getOrCreateBillable($owner, $tenant) does get populated registrations, and for those the hook runs normally. If that is your setup, the warning is expected and can be ignored.

Deploying — drain the notification queue BEFORE you deploy, not after. DeliverNotification is new, so nothing can be queued as it during the upgrade. But notifications queued as SendQueuedNotifications before the deploy carry the OLD shape: the recipient as a ModelIdentifier, no anchor, no context activation. Those payloads still deserialize and run — that is all "compatible" means here — and they run the pre-fix path this release exists to remove. On a single-database host that is harmless. On a per-tenant-database host it is not: such a job re-hydrates its recipient against whichever tenant database the worker's connection currently targets, so draining it after the deploy can mail or store another tenant's data.

So: let the queue run empty while the old code (and your own tenant-aware queue handling) is still deployed, or delete the pending payloads and accept the lost notifications. Do not deploy with a backlog and drain it afterwards.

Two practical notes, because "the notification queue" is rarely a thing that exists:

Config keys the package adds, and why yours may not see them

notifications.first_payment_url is new. If you published config/larsasub.php, you will not get it by upgrading: mergeConfigFrom merges at the TOP level only, so your notifications array replaces the package's wholesale and every key you did not copy is simply absent. Setting LARSASUB_NOTIFICATIONS_FIRST_PAYMENT_URL in .env then has no effect either — the env call lives in the array you replaced.

The symptom is a boot warning you cannot resolve by editing .env. The fix is to add the key to your published config by hand:

'notifications' => [
    // ...your existing keys...
    'first_payment_url' => env('LARSASUB_NOTIFICATIONS_FIRST_PAYMENT_URL'),
],

This is the same mechanism that hid the model-logger channel in 0.13.x. It applies to every nested key this package adds, so after any upgrade it is worth diffing your published config against the shipped one.

monthlyTotal() moves with the VAT change

With invoicing.prices_include_vat = false, SubscriptionCostService::monthlyTotal() returns the amount that will be collected — which now includes VAT. A €9,95 plan with a €4,95 module reported €14,90 before and reports €18,03 now.

That is correct (it matches what is charged), but it makes a UI that shows this number next to a separately calculated one inconsistent: the preview says €14,90, the confirmation says €18,03, and the label just says "per month". If you render it, either label it as including VAT or compute your preview through the same call.

Also worth knowing

Test kit: LarsaSubFixtures::mollieCustomer() is find-or-create

It now does firstOrCreate on (billable_id, tenant_id, tenant_type) instead of a plain create, matching production. Two usableMandate() calls for one billable therefore share one Mollie customer row where they used to leave two. A host test that asserted the old (impossible) state needs adjusting; explicit overrides still win, and tenant-scoped customers are still separate rows.

0.13.0

0.12.0

0.11.0

Audit phase 5 plus a production-readiness review of the whole package. No schema changes. Three items need a decision on every installation; the rest is tightening that only bites if you were relying on the old behavior.

Behavior changes that need no action, but explain a difference

0.10.0

Audit phase 4 (package fit + the design-level compliance items). One migration was edited in place (pre-1.0 policy, see the note at the top), one shipped default changed, and several behaviors tightened. Two of the items below need action on every installation: the invoicing default and the view-publish path.

0.9.0

Audit phases 2 (trust boundaries) and 3 (scale). Three migrations were edited in place (pre-1.0 policy, see the note at the top) and several behaviors tightened. Hosts that published the dashboard assets should re-publish them.

0.8.0

Phase 1 of the audit: invoice numbering, credit bookkeeping and credit notes. Two columns change and one method loses a parameter. The rest is behaviour that needs no action.

0.7.0

Seven fixes from the package audit — the five it classed as "stop the bleeding" plus the first two money-correctness items. Nothing to configure and nothing to migrate, but five of them change what you collect or reject input that used to be accepted. Those five come first; the last two need no action.

0.6.0