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 migrateis all an upgrade needs. Up to 0.37.1 the package edited its migrations in place: a database created from an earlier0.xrelease can still miss columns those edits added. Runphp artisan larsasub:doctoronce to find them; the release sections below say what each edit added, andmigrate:freshfixes a development database.
0.38.2
No schema change.
- Headless hosts that pass a mandate.
createSubscriptionWithMandate()now throws aBillableSubscriptionServiceExceptionfor a mandate the bundled subscribe endpoint already refused: another billable's, one in another tenant's Mollie account (Connect), an unusable one, or one of another payment method. In Connect mode a billable whose tenant cannot be established is refused as well.createDeferredMandateSubscription()now checks the tenant too. Where your code calls either, catch the exception as you catch the other refusals, and pass the subscriber's own mandate. - Custom providers. In your
createSubscriptionWithMandate(), callBillableSubscriptionService::assertReusableMandate()before writing anything, link the mandatelockMandateForReuse()returns inside the creating transaction, afterBillable::lockSubtree(), and collect the first charge from that returned row, not from the object you were handed — see Adding your own provider.
0.38.1
No schema change.
- Published views. If you published the views, your copies of
partials/standalone-head-assets.blade.phpandpartials/standalone-scripts.blade.phpstill load the dashboard assets without a version. Load them through\PetersDevelopment\LarsaSub\Helpers\GeneralHelpers::dashboardAssetUrl('<file>')as the shipped partials do, or delete the copies. - Published Vue sources. The
larsasub-vue-componentstag now also publishesresources/vendor/larsasub/css/larsasub.css. Run the publish again to get it; files you already have are left alone.
0.38.0
No schema change.
-
Republish the dashboard assets. The subscription and tenant charges bundles changed (the plan picker's feature labels, the "owed after chargeback" tab).
php artisan larsasub:installdoes it, orphp artisan vendor:publish --tag=larsasub-assets --force. -
Published Vue sources (
--tag=larsasub-vue-components): inSubscriptionCreator.vuereadprops.featureLabels?.[key]instead ofprops.translations?.features?.labels?.[key], pass:feature-labels="props.featureLabels"fromMySubscriptionDashboard.vue, addfeatureLabelsto both props types intypes/index.ts, and mapfeatureLabelsandfollowUpChargesin your own entry files. -
Published credit-expiring mails. If you published the views, the two
emails/credit/*/credit-expiring.blade.phpcopies still print$transaction->amount, the amount originally added. Print$transaction->remaining_amount ?? $transaction->amountinstead, which is what expires. -
Published translations. The
warningline ofnotifications.user.payment-failed,payment-canceledandpayment-expiredno longer threatens a suspension. A publishednotifications.phpkeeps the old sentence; change it unless you do suspend access yourself. -
CreditTransaction::expirationDue()leaves out additions with nothing unspent. If your own code used the scope to list everything that expires soon, spent or not, add the condition back yourself. -
Connect: documents issued bare after a disconnect. A disconnected tenant's invoices and credit notes now keep its reference code. One issued before this release, while the tenant was disconnected, carries a bare number (
INV-000042) from the tenant's own sequence, and the platform's sequence collides with it once it reaches that number. To check, look for tenant documents without the code in front, for exampleInvoice::query()->whereNotNull('tenant_id')->where('invoice_number', 'like', 'INV-%')->count(), and the same onCreditNotewithcredit_note_number(use your own prefixes).- Whatever the counts say, run
php artisan larsasub:generate-missing-invoicesonce. An invoice that failed on such a collision left no row for the query to find; it is now issued with the code. A credit note has no re-run command: look in your log for unique-index failures oncredit_note_numberand issue each one withInvoiceService::createCreditNote()(see Refunds). - When the counts are not 0 and the platform issues documents itself (dual
billing), move the platform's sequence past the highest bare number
found: set
last_numberon itsInvoiceSequencerow (tenant_key = '', the typeinvoiceorcredit_note; create it if the platform has issued none yet) to that number, so its next document skips them. The numbers skipped are a gap in the platform's own series; record why.
- Whatever the counts say, run
-
A subclass of
BillableSubscriptionServicethat overridesretryFailedFirstPayment()orterminateFailedSubscription()adds the new trailingbool $skipOwnershipCheck = falseparameter to its signature. Without it PHP refuses to load the class ("Declaration … must be compatible"). -
Published Vue sources moved.
--tag=larsasub-vue-components(andlarsasub:install --vue-components) now publishes toresources/vendor/larsasub/js/components, together with theutils,types,composablesandfrontend.jsonthe components import. A copy you published before stays where it is, inresources/js/components/larsasub, and is never overwritten. To move, publish again, point your imports at the new location and delete the old copy, together with theutils,types,composablesandfrontend.jsonyou copied beside it by hand; your own edits to the old copy do not move with it. -
policies.contextis gone from the config. It was never read. Delete the block from a publishedconfig/larsasub.phpat your convenience. -
billable_subscriptions.vat_rateis an override now, not a copy. New subscriptions leave it empty and follow their plan's rate, theninvoicing.vat_rate, at every charge. A subscription created before this release still carries the rate copied from its plan, and that copy now acts as an override: it keeps the subscription at that rate. To let such a subscription follow its plan, clear the column. Code of your own that copiesvat_ratewhen it creates or imports a subscription should stop doing so. -
A plan rate change reaches running subscribers. On exclusive prices, and for a zero-rated customer on inclusive prices, editing a plan's
vat_ratechanges what its subscribers are collected from their next charge, and they are mailed (notifications.user.subscription_vat_rate_changed, on by default). Switch the toggle off if you send your own notice, and listen toSubscriptionVatRateChangedEvent. -
Published views: the new mail is
emails/billable-subscription/user/subscription-vat-rate-changed.blade.php. -
Strict lazy loading: code of your own that reads
getEffectiveVatRate()on subscriptions from a list should eager-loadvatPlan.
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:
- A continuous subscription without an end date now always gets a date.
With cancellation notice
none, or a notice shorter than a quarterly or yearly interval, it answered null once the running period's charge had been sent. If your own screen showed a fallback for that null, it now shows the planner's date. - Null for a subscription the planner does not collect: not activated yet,
deactivated, or not chargeable (
is_chargedoff, or a €0 price without a billable cost line). A free plan answered "today" before. If you printgetNextBillingDate(), render null as "no next payment". A charge that already exists (a first payment in checkout, a standalone charge) is not part of this answer.
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:
- The planned row appears on its collection day. It is collected in the
same scheduler run. If you show an upcoming debit from the charge row —
"next debit €…" — it is no longer there in advance. The date is
BillableSubscription::getNextBillingDate(). For the amount,Support\MonthlyAmountSummary::for($subscription)gives the usual debit per period (introPeriodTotalduring an introduction) — without a cost line's catch-up and without credit. The exact amount of the coming charge isSubscriptionCostService::calculateChargeAmount($subscription, $date)['amount']for that date, less the available credit (CreditService::getAvailableBalance(), up to that amount); it composes the charge without writing anything. The bundled billing history and the tenant charges dashboard's "Planned" tab follow the row. - Schedule plan-charges before execute-charges. The package's own
registration does (both
daily, plan first). Ascheduling.plan_charges_frequencyofweeklyormonthly, ortwiceDaily(01:00 and 13:00) against adailyexecute run, now collects late; boot logs a warning andlarsasub:doctorlists it. Set it back todaily. Withscheduling.auto_registeroff you register both yourself: run plan-charges at or before the first execute-charges run of every day. Nothing can check that for you. - Ending a module "at the end of the period" —
endLine()per the last day of the running period — no longer collects it once more and credits it back. If you kept a module running until thebilled_throughof a charge planned ahead, to avoid exactly that, you can end it at the period end now. RecurringChargeService::planChargeForCurrentMonth(), if you call it yourself, returnsnulluntil the collection day.- Setting
notifyon a planner charge yourself (unsupported) no longer announces anything: the row is created on its collection day, andlarsasub:notify-chargesonly announces a date still ahead. - Credit with an expiry date between the 1st and the collection day now expires instead of being taken by a charge planned early. Credit given before the collection day is applied to that day's charge.
- Charges planned ahead by an earlier version are collected as planned.
Nothing to migrate; until they are collected,
getNextBillingDate()skips the period they occupy, as before.
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.
MollieConnectService::disconnect()no longer exists. CallrequestDisconnect($tenant)— the account drains: it stays connected until every payment under way has a final status, nothing new starts for the tenant meanwhile, and it is removed afterwards (at once when nothing is under way) — ordisconnectNow($tenant)for whatdisconnect()did. Both return aSupport\DisconnectImpact;->disconnectedsays whether this call removed the connection.cancelDisconnect($tenant)calls a drain off.- Show the drain.
getStatus()carriesdraining,disconnect_requested_atanddisconnect_deadline_at;disconnectImpact()gives the open count for a confirm dialog. A headless host points its disconnect dialog atrequestDisconnect(), offers "now" as the explicit second choice, and shows the drain while it runs. - A drain ends in the last pass of
larsasub:execute-charges. Without the scheduler, callMollieConnectService::finalizeDisconnects()yourself — a drain never ends on its own otherwise (nothing is lost; the tenant just stays connected).connect.disconnect_drain_days(LARSASUB_CONNECT_DISCONNECT_DRAIN_DAYS, default 30) is the ceiling; a config published before this release lacks the key and reads the default. - While a tenant drains, every new payment for it refuses with
Exceptions\ConnectDisconnectPendingException(extendsPaymentProviderServiceException, so existing catches still catch it). A custom payment provider refuses nothing:refusesNewPaymentsFor()defaults to false. - The new company mail
connect_disconnected_open_paymentsis on by default; a published mail layout needs nothing new. - Calling the disconnect route yourself: DELETE
larsasub.mollie.connect.disconnectremoved the connection at once in 0.35.0; that is nowmode=now, and without amodeit drains. Sendtenantas before. A 422 answerserrorwith a translated sentence instead of the literalNot connected— key on the status code, not the text. POSTlarsasub.mollie.connect.cancel-disconnect(new) calls a drain off.
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.
- Republish the assets:
php artisan vendor:publish --tag=larsasub-assets --force. The browser loads the bundle frompublic/vendor/larsasub, and the frontend texts are built into it. An old bundle keeps the connect page showing its header alone (the fix under Fixed does not reach the browser), shows no dialog or drain banner, and keeps the subscribe card open during a drain, where a click is refused.larsasub:doctoronly reports assets that are missing, not ones that are out of date. - Published Vue sources (
--vue-components): portTenantConnectDashboard.vue,MySubscriptionDashboard.vueandtypes/index.ts; give the connect pageconnect,disconnect,drainDaysandcancelDisconnectUrlfrom the payload, and the charges, subscriptions, subscribers and fees pagesconnect; add the newtenant_admin.disconnect_*,tenant_admin.draining_*andconnect.draining_member_*keys fromresources/lang/*/frontend.jsonto your build. - Published views: if you published
tenant-connect-dashboard.blade.php(--tag=larsasub-views), addcancelDisconnectUrl,disconnectanddrainDaysto its payload — republishing needs--force, which overwrites every published view. Without them the dialog names no count and no ceiling, the drain banner shows neither the request date nor the deadline, and it has no cancel button; with re-authorising hidden during a drain, the tenant owner then cannot call a drain off from the dashboard.
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:
- 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. - Set
LARSASUB_VAT_VALIDATION_ENABLED=true.larsasub:doctorcounts 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.
- Republish the assets:
php artisan vendor:publish --tag=larsasub-assets --force. The browser loads the bundle frompublic/vendor/larsasub; an old bundle keeps printing the catalogue price, andlarsasub:doctoronly reports assets that are missing, not ones that are out of date. - Published Vue sources (
--vue-components): portSubscriptionCreator.vue(andtypes/index.tsif you copied it) — the card now reads each plan'squote— and add the four newcreator.*keys fromresources/lang/*/frontend.json(order_button,due_now,action_profile,action_payment_method) to your build. - Published views: nothing to do. The quote travels inside each
availableSubscriptionsentry, which the dashboard view passes on whole. - The button text on a paid plan is now "Order with obligation to pay" /
"Bestelling met betalingsverplichting" (NL BW 6:230v, CRD art. 8(2)); a free
plan keeps "Get started". It is built into the bundle: a published
frontend.jsondoes not change it. - The profile or payment-methods dashboard switched off: the refused card
links to the dashboard that resolves it only when that dashboard is on.
Without the
profiledashboard, on exclusive prices with invoicing on, your own billing form must storeinvoice_countryon the registration, or a subscriber without one cannot take out a paid plan; the same goes for adding a payment method underfree_plan_mandate = 'refuse'withoutpayment_methods. - A
subscription.before_first_chargehook must also implementContracts\ProspectiveCostLines, or no plan can be taken out from the dashboard: the endpoint cannot name what the hook adds and refuses. Return fromprospectiveLines($plan, $owner)the lines the hook adds, and let__invoke()add exactly those (the contract's docblock has the recipe). The boot log andlarsasub:doctorname the hook until you do. - Prices excluding VAT (
invoicing.prices_include_vat = false): a subscriber with no country stored is asked to fill it in on the profile dashboard before subscribing — the amount depends on it, and the package does not guess. A free plan needs no country: it collects nothing. subscription.free_plan_mandate = 'refuse': the endpoint now refuses before the terms are recorded, with "add a payment method first".
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).
- Published PDF views (
--tag=larsasub-invoice-views) keep their oldnumber_format(…, ',', '.')andd-m-Y. They already render inside the customer's locale; republish them (--force) and re-apply your changes to get the number and date formats too. - Published mail views keep their old amount until you republish them:
EUR 49,95in the single-payment mails,€ 49,95(whatever the currency) in the needs-attention mail.
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.
- If your code tests for "no VAT in this amount" with
=== VatTreatment::Excluded(or== 'excluded'on the column), use! $treatment->includesVat()instead — it is false for both. - A
matchoverVatTreatmentwithout adefaultarm throwsUnhandledMatchErroron the new value; add the arm.
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.
- An admin screen of your own that explains attention markers: add this one. Its advice is "only the tenant can see in Mollie whether this was paid; settle it by hand" — clearing the flag books nothing.
- Code that catches the resolver's refusal keeps working:
ConnectAccountDisconnectedExceptionextendsPaymentProviderServiceException. Catch the subclass to tell "this tenant disconnected" from "this tenant has no account". - Your own tests that disconnect a tenant (or call
markAuthFailed()) with payments under way will now see them flagged. - In this release a disconnect is still immediate. A dialog that promises "existing payments will still be processed" is not true on 0.34.0 or 0.35.0: the money still reaches the tenant's Mollie balance, but the package can no longer book it. 0.36.0 makes a disconnect drain first; see 0.36.0.
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.
- Charges flagged before this release carry no marker, so a flagged
collection that used to show a pay button (say, "No mollie mandate found.")
no longer does. Clear its flag: the next
larsasub:execute-chargesasks Mollie, releases the claim when nothing is there, and collects it again in the same run — or flags it again, with the marker, if the mandate is still missing. That needs the scheduler (scheduling.enabled), and a claim whose lookup cannot answer (a history beyondreconcile_lookup_ceiling, an unreachable tenant) is parked again instead. executeCharge()refuses a charge that was already collected once (a provider id or status on the row) or is flagged (needs_attention): it collects planned charges only. A host that re-collected failed charges through it gets aLarsaSubException; the customer pays a failed charge by hand, and re-collecting by mandate needs a pre-notification the package does not send. Clear a flag first to hand a charge back to the scheduler.- Your own tests that build a flagged, claimed charge with
LarsaSubFixturesand expect a pay button need the marker in itsattention_message(or a provider status on the row). - Your own code that flags a failed
executeCharge()should call$charge->flagFailedCollection($exception, $message)instead ofsetNeedsAttention(); without the marker the charge stays unpayable. - A custom provider: throw a
LarsaSubExceptionfromexecuteCharge()when nothing was sent, so the charge stays payable; anything else is treated as possibly collected. Never wrap an ambiguous failure (a timeout, a 5xx) in aLarsaSubExceptionwithout passing it asprevious— the classification reads the cause.
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.
- A published config keeps
refund(itsenv()default is yours). SetLARSASUB_DUNNING_DOUBLE_PAID=signal, or change the key, to follow the new default; keeprefundif you rely on it. - Keep
notifications.company.payment_needs_attentionon, or watchneeds_attentionyourself — withsignalthat mail is the only notice. The boot check andlarsasub:doctorwarn when it is off. creditgives it back automatically as wallet credit, without Mollie.
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).
- A published config gains
dunning.reminders, the twodunning_reminderkeys undernotifications.user/.company,retention.dunning_days(null: resolved series are kept) andscheduling.process_dunning_frequency/scheduling.prune_dunning_cases_frequency. Only missing top-level keys are filled in from the package: a config published before this release has adunningblock withoutreminders, soLARSASUB_DUNNING_REMINDERS_ENABLED=truesilently does nothing until you copy the wholeremindersblock in (and the two notification keys).larsasub:doctorlists missing keys. Thestepslist is validated at boot even while the series is off: an invalid one is a boot failure, and so is a key other thanafter_days,actionandnotify, ornotify => falseon anotifystep. larsasub:prune-dunning-casesandlarsasub:process-dunningare scheduled daily (seventeen scheduled commands now). Withretention.dunning_daysnull the prune does nothing, and withdunning.reminders.enabledoff the run does nothing. Scheduling the commands yourself (scheduling.auto_register = false)? Add both to your scheduler — nothing can tell you they are missing, and withoutlarsasub:process-dunningno series ever opens.- Switching it on — the checklist is in
Reminder series: copy the block, set
LARSASUB_DUNNING_REMINDERS_SINCE(Y-m-d; required once on), preview withlarsasub:process-dunning --dry-run --since=…, make sure the scheduler and a non-syncqueue run, and setnotifications.payment_urlwhen the payments dashboard is off or in explicit billable mode — thepayment_urlinvokable now also receives reminder charges, including standalone charges without a subscription and chargeback follow-ups. - The reminder mail to the customer is on
(
notifications.user.dunning_reminder); its company copy is off. If you remind customers yourself, switch the user key off and listen toDunningReminderDueEvent, or they get both. - Escalation is yours. An
escalatestep dispatchesDunningEscalatedEventand mails no one; the package never cuts off access. Deduplicate on (event class, case, step), re-read$event->case->fresh()->status(the last step's events readexhausted), and decide access withpaidThrough().larsasub:doctorwarns when the schedule escalates and nothing listens. - Behaviour change: a payment of a charge with a running series now
resolves it straight away and dispatches
DunningResolvedEvent— also with the series switched off. larsasub:prune-billables --orphanedhas a new skip reason,has-open-dunning-case(OrphanBlocker::HasOpenDunningCase): a running reminder series blocks the registration's teardown until it ends. If you branch on the report'sskippedkeys, add it.- Policies:
larsasub.policies.models.dunning_case(admin only;closefor admins, no writes) anddunning_step(admin only, read-only). A published config without these keys denies every action on the two models — add them from the shipped config. - Companions: update
larsasub-nova/larsasub-filamentin the samecomposer update; their constraint on this package moves with the release. - The enums a reminder series stores (
Enums\Dunning*) and what each value means: Reminder series.
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:
- Your own charge queries — the package's relations included. A follow-up
has
charged_atNULL, is due, and belongs to the subscription, soplannedRecurringChargesandunpaidRecurringChargesreturn it. Where you select planned or unpaid charges to execute, delete, count or show "next payment", add->notChargebackFollowUp(). Passing one to the provider'sexecuteCharge()throwsLarsaSubExceptionbefore anything is claimed. - Status badges on
charged_at. A badge that readscharged_at === nullas "planned" shows a follow-up as planned. AskisChargebackFollowUp()first. - Counting paid charges or money received. The original keeps
paid_on; a paid follow-up is a second paid charge for the period. For cash received, sumreceivedAmount()— never "leave out charges with a standing chargeback", which drops the uncharged-back part of a partial chargeback and counts a refunded double payment twice. (A double payment given back as credit is not subtracted — it is still with you as wallet balance.) For paid periods, leave out charges wherehasStandingChargeback()is true. RecurringChargePaidEventlisteners also see a follow-up — and one settled by credit fires before the chargeback event. Seedocs/events.md.- Your own chargeback handling. If a
RecurringChargeChargebackEventlistener already creates a charge to re-collect, turning this on makes two claims for one debt — keep one. - Your payment screens gain it through
payableCharges()/outstandingCharges(). Nothing to do if you already render those. - Plan changes throw
BillableSubscriptionServiceExceptionwhile a follow-up is unpaid; its message is the translatedlarsasub::billable-subscription.change-open-chargeback-follow-up. The bundled dashboard shows it; a headless host renders it (askopenChargebackFollowUp()first if you need to branch). - A reversed chargeback withdraws an unpaid follow-up (soft-deleted), and a
paid one is given back per
dunning.double_paid— a refund by default. If your own code already refunds onRecurringChargeChargebackReversedEvent, setdouble_paidtosignalor drop yours, or the customer gets it twice. NotPayableReason::ChargebackReversedis a new case. Amatchover the enum without a default arm needs it.- A withdrawn follow-up's payment that fails, expires or is canceled fires
RecurringChargeFollowUpWithdrawnEventinstead of the failed/expired/canceled event. - A follow-up whose manual payment start died between the claim and
Mollie is recovered by
larsasub:execute-charges(a fourth reconciliation pass, its own slice ofexecute_charges_limit) — only with the scheduler running. - The chargeback mail's pay button goes through
notifications.payment_url: an invokable there now also receives a follow-up (never the charged-back original). Branch on$charge->isChargebackFollowUp()if your pay page needs to. - The chargeback mail's attachment. With
notifications.attachments = 'invoice'it no longer attaches the voided invoice once a follow-up exists; an attachment invokable still receives the charged-back original.
The chargeback mail and the dashboards: for every install
These apply whether or not dunning.chargeback_follow_up is on.
- Re-publish the built assets — every bundle changed.
php artisan vendor:publish --tag=larsasub-assets --force. Your published copy inpublic/vendor/larsasubis what the browser loads: without it the pay button still opens a dead checkout after a failed manual payment, follow-ups read as planned, and the operator dashboard has no "owed after chargeback" tab. If you published the Vue sources (--tag=larsasub-vue-components), port the changes toRecurringChargesDisplay.vue,TenantChargesDashboard.vue,utils/index.tsandtypes/index.ts. - The customer chargeback mail has a new subject and title, states the
bank's reason as one sentence (never the code), and no longer threatens a
suspension that nothing performed. It is not sent for a chargeback that
was reversed by the time it goes out — no
NotificationSending/Sentfor it. A published copy of the view (vendor:publish --tag=larsasub-views) still renders — the one key it prints that the package dropped is kept, neutral — but never says where a follow-up stands; copy the shipped view over yours (larsasub:doctorwarns). Published translations keep your old subject and title — and your oldconsequencesline, the suspension threat, which an old published view still prints: update that line too. - The operator chargeback mail shows the bank's reason code and, with dunning on, the follow-up and its state. A published copy of that view keeps the old content — re-publish it.
- The bundled dashboards label a paid charge whose chargeback still stands
"charged back", and a reversed chargeback no longer keeps its original in
the operator's "failed" list. With dunning on, a follow-up reads "due after
chargeback" (payable ones on top) or "no longer due", and the operator gets
an "owed after chargeback" tab and figure. A published
tenant-charges-dashboard.blade.phpdoes not pass the new tab its rows — re-publish it, or addfollowUpCharges. Headless hosts ask the models:isChargebackFollowUp(),notPayableReason(),hasStandingChargeback(),openChargebackFollowUp()(the dashboards' payload is internal).
Refunds and a few behaviours
RefundService::refund(), CreditService::returnAppliedCredit() and
CreditService::returnCollectedAsCredit() are new; nothing existing changed
signature. These behaviours change without action:
- The tenant dashboard's "received this month" now subtracts completed refunds (and the charged-back part) from each charge paid that month, so a month with a goodwill refund shows less than before.
- A
notifications.disabled_listenersentry written with a leading backslash ('\\PetersDevelopment\\…'as a string) used to disable nothing; it now disables that listener, and its mail stops. provider_payment_statusbecomesrefundonly when a refund completed. A payment that showsrefundbecause of a refund that never completed goes back topaidon its next refresh — the next webhook for that payment, or arefund()call on it. There is no backfill: Mollie sends no webhook for a canceled refund, so such rows keeprefunduntil something refreshes them. A host that filters onprovider_payment_status = 'refund'should readprovider_refund_detailsinstead.- The plan-change credit nets refunds (in flight included) and standing chargebacks off what was paid instead of dropping to €0,00 on any refund or chargeback entry. A customer with a small goodwill refund now gets the prorated credit they are owed.
paidThrough()counts a charge whose chargeback was reversed.
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.
- Schema.
larsasub_invoice_sequencesno longer has ayearcolumn and its unique is(tenant_key, type). The migration is edited in place: runphp artisan migrate:freshon a development database. - Code that writes
InvoiceSequence. Drop'year' => …from anycreate()— a seed script from the old recipe now fails on an unknown column. Seedocs/recipes.md. - Code that parses a number. Anything that read the year out of
invoice_numbermust useinvoice_dateinstead. - Nova. Update
larsasub-novato 0.30 in the samecomposer update: its invoice-sequence resource searched the droppedyearcolumn.
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:
- a pass or platform step you call yourself establishes its context per
step:
SettlementService::syncPlatform(),syncTenant()for a connected account that is the platform's own organization, thePaymentReconciliationServicepasses andrecoverMandateFor(), andBillablePruneService::pruneOrphaned(). With a hook, call them from a command or a job; - a LarsaSub notification on the
syncqueue runs inside the dispatching request.
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:
- the platform charge is parked with
needs_attention; the planning loop skips the platform subscription this run (and says so) — the next run plans that period; - the webhook for a platform payment answers 500, and Mollie retries;
- a platform notification in the user funnel is retried and lost after three attempts; a company notification sends the platform sales copy after three;
- the prune skips the platform billable as
tenant-context-unavailable; - the settlement platform pass is skipped with outcome
tenant_context.
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.
- Bundled dashboards: nothing to build. The tenant-connect dashboard now
offers a connected tenant owner "Re-authorize Mollie", and puts it up front
with an explanation while the account's
settlements_sync_errorisscope_missingorunauthorized. If you published the built assets, republish them (--tag=larsasub-assets --force); every bundle changed. - Headless hosts: link the tenant owner to
route(config('larsasub.connect.authorize_route'), $tenant)from your own page, and useMollieConnectService::getStatus($tenant)['reauthorization_needed'](new, withreauthorization_reason) to decide when to ask. See Re-authorising a connected tenant.
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:
- The language. Unset, it is
app.localeas it stands when the package's provider boots — which is before yourAppServiceProvider. If you set the language withApp::setLocale()there or in middleware, that does not reach the History: setLARSASUB_LOG_LOCALE. Set it too if the History should simply be in another language than your app default. The package shipsenandnl; for any other language the boot warns unless the lines actually translate, andlarsasub:doctorshows the log language and whether it renders. - Code that matched log text. The English wording changed. Match on
meta->message_keyinstead, e.g.$model->logs()->where('meta->message_key', 'payment.log.charge-paid'). - Old rows are not rewritten. A History shows the old English lines
above the new ones until
larsasub:prune-model-logsretires them (retention.model_logs_days).
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:
- An owner model with its own
logKey().IsBillableModelnow brings a publiclogKey(), and a method of that name on your model silently replaces it — the package writes the owner's history through it. Rename yours, or keep the trait's signature. An owner that implementsBillableModelwithout the trait needs nothing. settleLocally(reason: …). The parameter is now$reasonKey; a named-argument call must usereasonKey:or pass it positionally.
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 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:
| 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:
- A query-builder mass update (
BillableSubscription::query()->update(...)) dispatches no model events and is therefore not guarded. That is the known package-wide class, not a new hole.larsasub:doctornow lists the cost lines such a write left behind — as candidates to check, not as proven breakage: after the fact nothing in the data contradicts anything, so a deliberate "module starts next month" looks identical to a stranded line. larsasub-novaandlarsasub-filamentexposestart_dateas an editable date and ship in lockstep. Until they route throughmoveStartDate(), editing that field in those panels surfaces the exception rather than silently mis-collecting — which is the intended failure, but it is a visible change for whoever uses those screens.
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:
- If you published the views or the layout, the mail title changed shape — the next section, and the layout case is visible in dark mode.
- If you published
lang/, three keys were added and six removed; the last two sections.
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:
- Nothing was ever wrongly collected, and
totalis unchanged: it is still literally the collected amount. Only the net/VAT split of an invoice with two or more lines moves, by cents. - Invoices already issued are not rewritten — they are immutable
snapshots. Reports that sum stored
vat_amountacross a period spanning this upgrade will therefore contain both splits; the totals are unaffected. - Single-line invoices are unchanged: first payments without a breakdown, legacy rows and single-amount payments keep de-grossing the collected amount. Single payments with order lines were already per-line, and reverse-charge / 0% invoices de-gross nothing at all.
- Credit notes derive their VAT from the invoice pro-rata, so they follow automatically. No action.
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:
- Existing rows are NOT migrated. A plan that already carries a €0 introduction price keeps working exactly as it does today — that is an agreement already made with paying customers, and changing it would change what they are charged. Only new ones are prevented.
- The rule fires on a WRITE of
introduction_durationorintroduction_price_amount, not on every save of such a row. So an existing plan stays editable and, above all, stays switch-off-able: an unconditional rule would have made one legacy row abort the wholelarsasub:end-subscriptionsrun (it deactivates expired plans with a bareupdate(['active' => false])inside a loop) and leave every other expired plan on sale. What throwsLarsaSubException(larsasub::subscription.introduction-price-minimum) is an update that SETS the zero price, or that adds an introduction period to a plan whose introduction price is 0. - On create everything counts as written, so a new plan is covered unconditionally. The bundled tenant form is stricter still: a form post states both values deliberately, so it validates the pair whatever changed.
- With no introduction period, an introduction price of 0 stays correct: the
model normalizes it to
'0.00'itself, and the form's minimum is conditional onintroduction_duration. Saying nothing counts as saying none: a create OR update that omitsintroduction_duration(or sends null/'') stores'none'+'0.00'instead of failing on the NOT NULL columns. On update that normalization only applies when the payload writes one of the two introduction fields — an unrelated edit leaves an existing introduction period exactly as it stands. - A NEGATIVE introduction price is refused too, and that is new: the model
compares
(float) $price < 0.01, so 0, negative and sub-cent are one rule and the model says what the form says. If you seed or import plans with a negative introduction price, they will now throw on write. - If your own admin UI validates this field, mirror the condition rather than
applying a flat
min:0.01— that would reject every ordinary plan.
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.
'mandate'— activate AND send the customer on to the €0,01 mandate verification, so cost lines added later have something to collect with. The activation stands whether or not the customer finishes it. Nothing new is started when there is already something to collect with: a subscriber who holds a usable mandate is left alone, and a verification that is still running is resumed.'refuse'— reject a free-plan subscription with aBillableSubscriptionServiceExceptionbefore anything is created, for a subscriber who has nothing to collect with. It fires inBillableSubscriptionService::createSubscription(), so it covers every entry point that reaches it — the subscribe flows and a plan change whose TARGET is a free plan — but it carries the same mandate condition'mandate'does: a subscriber who already holds a USABLE mandate is left alone, because the thing the key protects against (a free subscription with no way to collect cost lines later) is not the case. That is whycreateSubscriptionWithMandate()andcreateDeferredMandateSubscription()are never refused: both require a usable mandate by contract, and they both run through the same shared service method. If you want free plans gone altogether, retire the PLAN (active = false) rather than configuring'refuse'.SubscriptionCostService::quoteFirstCharge()reports the same decision asChargeQuote::$creationRefused(andisComplete() === false,creation_refusedintoArray()) instead of quoting a tidy €0,00 for a plan the very next call throws on. With noowner, or an owner with no Billable registration, there is no mandate to find and the quote reads as refused — which is what the subscribe flow will do for that subscriber.ChargeQuote's constructor gained the parameter LAST with afalsedefault, so hand-built instances keep working.
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:
billableHasUsableMandate(?Billable $billable): bool— the onefree_plan_mandateasks. The default is derived from yourgetUsableMandatesQuery()and answersfalsefor anyTenantScopeit cannot honour, because a generic query cannot know how your provider ties mandates to tenants. If your provider is tenant-aware, override it.getRemovedMandatesQuery(): Builder— the mandates you hold as removed (soft-deleted rows). Derived fromgetUsableMandatesQuery(); a provider whose mandates are hard-deleted answers "none" and needs no override.recurringMethodForMandate(RecurringCharge $charge): PaymentMethod— which payment method a mandate is established on. Provider-neutral (it reads onlyPaymentMethodrows) and it is what the new mandate-recovery pass uses to decide the instrument, so override it if your provider does not pick the recurring method the waycreateMandate()does. It throwsPaymentProviderServiceExceptionwhen there is nothing recurring behind the charge.
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:
- A subscriber whose checkout was abandoned keeps a reachable retry and
terminate, instead of the row silently disappearing after 12 hours. The
bundled dashboard now also renders the plan picker next to the failed block,
so they can simply choose another plan; that POST succeeds, because the
idempotency guard filters on
isNotDeactivated(). - If you probe for "soft-deleted and never activated" rows, that shape no
longer occurs. Query the first-payment-failure family instead:
whereIn('deactivation_reason', DeactivationReason::firstPaymentFailureValues()), or$owner->failedFirstPaymentSubscription(). - If you switch on
deactivation_reason, add the new case. It is part ofisFirstPaymentFailure(), so anything keying on that family already covers it. - The row stays until the subscriber terminates it. Nothing else removes
it: taking a new subscription leaves the old row exactly where it is (the
idempotency guard simply ignores it), and
larsasub:prune-billables --orphanedonly ever touches registrations whose OWNER no longer exists — on an ordinary abandoned checkout that user is still there. Both are exits from the state, not cleanups of the row. Two bundled tenant dashboards (tenant-subscribers,tenant-charges) count these rows among the subscriptions they list. - A charge that was created but never submitted to the provider is
soft-deleted by the sweep, because after a successful retry the execution
loop would collect it on top. Its cost-line bookkeeping is rolled back
first, as before. (Soft-deleted: a host reading with
withTrashed()still sees these rows.)
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:
- A host route without parameters gains
?charge=…&subscription=…. Nothing to do. - A host route with an optional
{charge}(or{subscription}) segment goes from/returnto/return/12. Check anything that matches on the exact path.
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:
- Your invokable runs in two trust contexts and cannot tell them apart: the
authenticated subscribe request, and the four return endpoints, which carry
no
authmiddleware (their URLs live in payments at the provider and must work without a session) and bind{charge}from an id the caller chose. There is deliberately no argument distinguishing them, so the rule is uniform: route on the model, do not render it. Picking a destination is fine, and so is echoing back an id that was already in the incoming URL; amounts, descriptions and provider ids are not. Need those, check ownership yourself andreturn nullwhen you cannot. connect_callbackis resolved only where the tenant is known (the start of the OAuth flow). The origin is computed by the package as a security control and passed as the second argument; do not derive it from the request host.
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.
- Config key:
notifications.company.mandate_registration_failed(defaulttrue). Env:LARSASUB_NOTIFY_COMPANY_MANDATE_REGISTRATION_FAILED=false. - Re-publishing the config does not turn it off — with the key absent the
code falls back to
true. Set the env variable, or add the key to your publishedconfig/larsasub.php. - Want a single mail rather than two? Turn the generic one off
(
LARSASUB_NOTIFY_COMPANY_PAYMENT_NEEDS_ATTENTION=false) and keep this one: nothing repairs this state on its own, so a customer in it has to be found. - The typed hook for host-side handling is
Events\Mandates\MandateRegistrationFailedEvent, which fires under the same paid-only condition and regardless of the notification toggle. - Translations ship in en/nl and need no action, including on hosts with
published translations (
--tag=larsasub-translations). A published copy is an override, not a replacement: Laravel loads the package lines first and merges yours on top (array_replace_recursive), so a key that exists only in the package renders normally. Re-publish only if you want to reword the new texts yourself — and note thatvendor:publish --forceoverwrites every customization you already made in the other groups. Same for the new*-mandate-setupkeys andmethod.setup-already-started.
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:
- A fixture built from an interval plan now carries that interval. Before,
a quarterly or yearly plan produced a subscription on the monthly default, so
a test could look like it exercised interval billing while it did not. If a
test depended on the monthly default without saying so, state it in the
overrides (
'billing_interval_months' => 1). name/descriptionare copied RAW, as the service copies them, so a translated plan hands the subscription its full locale map instead of one resolved string. Property access still resolves to one locale; onlygetTranslations()and raw-attribute reads see the difference.
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:
- It CREATES mandates at Mollie, it does not only adopt them. On the EU and mandate-setup flows Mollie has already made the mandate and only the local record failed, so the repair is an adoption. On the NL flow LarsaSub itself makes the mandate out of the IBAN of the first iDEAL payment — there is nothing to adopt, and minting one is the only repair that flow has. If your compliance posture requires that no automated process establishes a SEPA authorisation, this is the paragraph to read twice. The selection is correspondingly narrow: only charges carrying the marker that failure's own catch writes, never a charge a customer repaired by hand with iDEAL, never one whose subscription has since been deactivated, and never one whose payment method the customer removed themselves.
- Your subscriber gets a mail. A recovered mandate goes through
MandateMarkedUsableEventlike any other, so the "payment method added" notice (notifications.user.payment_method_added, on by default) is sent — days or weeks after the payment it belongs to. That is correct (the payment method genuinely became usable at that moment) and it will surprise support if nobody told them. Turn it off per-install if you handle this contact yourself. - Provider-call budget goes up.
scheduling.execute_charges_limitis now spent up to three times per run plus the charges themselves: withlimit = 50a run is at most 50 mandate recoveries + 50 unresolved charges + 50 unresolved single payments + 50 collections. And a recovery is not one call: the stored payment details are refreshed first on every charge that reaches the repair, a registered mandate is read back afterwards, and an adoption walks the customer's mandate list (page by page, up to 250 mandates) in between. Budget two calls per recovered charge as the floor and several as the realistic case. If you set a limit against API rate or wall-clock, re-size it. - New config key, and there is no off switch.
scheduling.recover_mandates_within_days(LARSASUB_RECOVER_MANDATES_WITHIN_DAYS, default 30) bounds how far back a paid first payment may be and still be repaired — past it the state is left for an operator instead of retried forever. It bounds the reach, not the pass:nulland''mean 30, and0,falseor any non-numeric value are raised to a one-day window rather than honoured as "off" (ConfigValidatornow warns when it sees one). Nor can you unhook it: the pass runs insideRecurringChargeService::scheduleExecute(), the only public entry to the execution loops, so not running it means not collecting. A publishedconfig/larsasub.phpwill not have the key —config()answersnullthere, which is the default, so nothing breaks; re-publish or add it by hand if you want to tune it.larsasub:doctorlists it under the missing keys.
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():
- Take no lock on a
larsasub_*row in the billable's subtree before you call in. LarsaSub serializes every lifecycle path on thelarsasub_billablesregistration and takes it first; your transaction joins that order or inverts it. If you genuinely need a package row locked beforehand, takeBillable::lockSubtree($billable)first. - Keep the provider entry points OUT of your transaction.
createSubscription(),createSubscriptionWithMandate(),createMandateSetupPayment()andstartManualChargePayment()end in an HTTP call to Mollie. The package commits before making it, deliberately — wrapping them nests that commit as a savepoint and holds the registration lock across the round trip, and a rollback afterwards leaves a live checkout at the provider with no local charge behind it.
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:
- A
larsasub:doctor --strictin CI will now fail on it unless you schedulelarsasub:execute-chargesateveryFifteenMinutesor finer (30 + 15 = 45;hourlygives 90 andeveryThirtyMinuteslands on exactly 60, which is not under it) or lower the threshold to match your interval. A fresh install already produced warnings (no active payment methods), so--strictwas never green out of the box. - Neither the warning nor the doctor line says anything when
scheduling.auto_registeris off: your own scheduler sets the interval and the package cannot read it. That case is yours to check.
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:
- A typo'd frequency for
larsasub:cleanup-unused-billable-subscriptionsnow registerstwiceDaily, and forlarsasub:validate-mandatesweekly, where both previously landed ondaily. If you were relying on a misspelling to slow a command down, spell it correctly instead. - The helper is also what
ConfigValidatorandlarsasub:doctorread, so the interval they report is the one that gets registered.
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:
- Lines must exist before the call to be collected by it. The supported moment is the new hook below.
- Do not mutate cost lines while a first payment is still outstanding.
endLine()refunds prepaid days without asking whether the charge was paid, so a mutation in that window both mints credit for unpaid days and movesbilled_throughout from under the rollback that a terminal payment performs.
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 onblank(config('larsasub.subscription.before_first_charge'))while migrating — null,''and a whitespace-only value all mean "no hook" to the resolver, so a strict=== nullwould disable your listener for an empty env variable while nothing replaced it — or gate the hook on$previous === nullto 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:
- You registered
AdminUrlResolver::baseUrlUsing()/resolveUsing()yourself to work around this. Those registrations now count as the platform audience and are never consulted for tenant admins. Move them tonotifications.tenant_admin_url, or re-register them for the tenant audience. - You have your own company notification extending
BaseCompanyNotification. Build its action button with$this->adminAction($model, $label)instead of$model->admin_url; the accessor always answers for the platform, by design. - You send a company notification yourself (
Notification::send(...)in your own code, outside the package's funnels). Those render with the platform audience — the package only scopes its own delivery paths.
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:
- a subscription's first set of cost lines — effective on or before its
start_date, on a subscription that has never had a charge go out and that carried no other cost line when the act started (its opening composition, which the activation notice states as the monthly amount); - everything inside the new
SubscriptionCostService::silently().
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:
- The four that use the partial (
subscription-activated,…-renewal-announced,…-renewed,…-changed) just need the@includeabove. emails/billable-subscription/user/subscription-cost-changed.blade.phpreceives three new view variables:$vatNotice(the translation-key suffix —'','-gross','-reverse-charge'or'-net'),$vatRate(a formatted rate, e.g.'21') and$introMonthlyTotal. An override that ignores them keeps rendering the unlabelled total — no error, but the mail keeps stating an amount whose VAT it does not explain, which is the SEPA pre-notification problem this fixes. Copy the labelled rows from the shipped Blade.- Published translations gained 48 keys per locale across five families —
total-per-monthandtotal-during-introduction, each plain and with-gross/-reverse-charge/-net, underuser.subscription-activated,…-renewal-announced,…-renewedand…-changed; plusnew-monthly-total-*andtotal-during-introduction*underuser.subscription-cost-changed. Rather than transcribe that list, re-publish and diff against your edits. Nothing breaks if you skip it: a key you leave out falls back to the package's own string, and a locale you publish that the package does not ship (sayde) falls back to yourfallback_locale— English in a default install — so the worst case is an English label in a German mail, never a raw key.
--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.
- You already kept your translations in
resources/langby choice. Nothing is broken and there is nothing to migrate:langPath()points there, which is also where the old publish wrote and where the new one writes. Skip the rest. resources/langexists only because you ran the publish (your own translations live inlang/). Then yourlang/files are currently being ignored, and that is the damage — migrate below. Do it even if your LarsaSub overrides appear to work; that they work is the symptom.
For the second case, in this order:
- Move
resources/lang/vendor/larsasubtolang/vendor/larsasub. - Delete
resources/lang— check it holds nothing else of yours first. - Only now is
vendor:publish --tag=larsasub-translationsmeaningful 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:
- Cost-line refunds issued in that window were credited a VAT rate too high.
- Charges created with
invoicing.enabled = falsecarry an empty VAT snapshot, so an invoice generated for them later is still resolved against the live configuration. Correcting either means deciding what to do about money that already moved, which is a business call rather than a migration.
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:
- Charges are grossed up before collection. A domestic (non-zero-rated) customer on a €14,90 exclusive plan is now debited €18,03. Communicate this before the first run — for existing subscribers it is a price change in everything but name.
- Zero-rated customers are unaffected. EU B2B reverse charge and customers outside the EU owe no VAT, so nothing is added.
- A customer without an
invoice_countryis NOT grossed up. The package refuses to add 21% on a guess, exactly as it refuses to strip it on one. The invoice then splits the collected amount instead (€14,90 → €12,31 + €2,59), so the document still totals what was collected. Fill in customer countries if you want the full net price collected. SinglePaymentamounts are unchanged.createSinglePayment(amount: 100)still collects exactly €100 — that argument is, and stays, the amount that goes to the provider, whateverprices_include_vatsays. What changes is the invoice: it now splits that €100 into €82,64 + €17,36 instead of invoicing €121. If your host code passes NET amounts there, it has to gross them up itself; nothing will error, and the VAT you declare would otherwise be wrong.- Connect application fees follow the gross amount (unchanged behavior, now worth noting): with a percentage fee, fee income rises with the VAT.
- Existing credit balances keep their nominal value. Credit built up against net amounts buys 21% less service afterwards (€100 of credit covered €100 of service, and covers €82,64 of it now). No migration ships for this: it would retroactively mutate ledger amounts. Settle or top up outstanding balances before upgrading if that matters to you.
- A cent of rounding may appear on invoices. Lines are de-grossed one by one, so three €1,03 lines produce a €3,10 subtotal against a €3,09 list total, and the last line absorbs the difference. This is exactly what VAT-inclusive hosts have always had; it is new only in that exclusive hosts now see it too.
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:
- An exhaustive
matchwithout adefaultarm throwsUnhandledMatchErroron the new cases. Add the arms or a default. - Treat
parkedexactly likestarted: still in flight. It means the reconciliation's provider lookup failed while no provider payment id is recorded, so the €0.01 verification payment MAY exist and a late webhook can still complete a PAID onboarding. Do not clean the attempt up and do not send the user into a second setup checkout.releasedandfailedare the terminal ones (released= the lookup proved no payment exists and the leftover was removed; a new setup is safe).
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:
- The hook fires once per chargeback id; pair it with
onPaymentChargeback()on that same id. - A chargeback that is already reversed the first time the package sees it (late refresh, on-demand fetch, missed earlier webhook) delivers both hooks — chargeback first, reversal straight after. Netting the pair lands on zero. If you only implement the empty method, nothing changes for you.
- The payment's own status and
payableflag are not touched: the original payment is simply paid again.
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:
MandateService::canDeleteMandate()is removed. Its replacement isremovalBlockers($owner, ?$tenant): array<int, ?MandateRemovalBlocker>— the reason per mandate id,nullmeaning removable, collections hoisted so a list of mandates costs a fixed number of queries instead of two per mandate. Pass the tenant in Connect mode: it scopes the pool a replacement mandate is searched in, and without it another tenant's mandate counts as one. The method was deliberately not kept with an enum return type — a blocker is truthy, so everyif ($service->canDeleteMandate(...))would have silently inverted.ProviderMandateResourcegainedcan_delete_reason(the blocker value,nullwhen removable).can_deletestays a boolean and keeps its meaning.
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:
- the per-channel fan-out of
Notification::send(): one job now covers every recipient and every channel of one notification.via()is unchanged and still config-driven (larsasub.notification_channels); viaConnections(),viaQueues(),withDelay()/#[Delay], message groups, deduplicators and notification-levelmiddleware()on a LarsaSub notification: nothing reads them any more. Route the job instead if you need a specific queue;- per-recipient queue retries. A failing recipient is caught and logged, and the others still get their mail; it is not retried. That is deliberate — one job for the whole recipient set means a rethrow would mail the earlier recipients twice.
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:
-
SendQueuedNotificationsis not the whole backlog. LarsaSub's listeners are queued too, so a pendingCallQueuedListenerwrapping (say)SendSubscriptionActivatedToUserListenercarries the same old shape. Drain everything that can end in a notification, not one class. -
Most hosts run all of this on
default. Unless you routed notifications to their own connection or queue, there is nothing separate to drain — it is your ordinary worker. To see what is pending on the database driver:SELECT COUNT(*) FROM jobs WHERE payload LIKE '%SendQueuedNotifications%' OR payload LIKE '%LarsaSub\\\\Listeners%';On Redis,
php artisan queue:monitor defaultgives the size; the payload detail is not queryable there, so drain by emptying rather than by counting.
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
- A second boot warning appears when
standalone.guardnames a guard that noauth:entry in your middleware stack authenticates. It renders fine and only denies on the actions, which is why the validator says so at boot. - The model logger now respects a host-declared channel. If you declared
logging.channels.larsasubyourself, the package no longer overwrites it. Hosts that relied on the package's version appearing regardless should check their channel still points where they expect. outstandingCharges()lands on every owner model throughIsBillableModel. If your own model already has a method by that name, the trait's version is the one that wins — rename yours or alias the trait method.MandateSetupStatusgained values. A strict=== MandateSetupStatus::Startedis as brittle as an exhaustivematch:parkedis also "in flight", andreleasedandfailedare distinct endings. Check for membership of a set, not for one case.
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
-
IsPayablechanged two signatures (breaking). Both hooks gained a second parameter, and PHP fails at class load without it — the same compile-time break 0.14 adds a seventh hook with, so an upgrade that crosses both releases has three methods to touch, not one:use PetersDevelopment\LarsaSub\Support\PaymentChargeback; use PetersDevelopment\LarsaSub\Support\PaymentRefund; public function onPaymentRefunded(SinglePayment $payment, PaymentRefund $refund): void public function onPaymentChargeback(SinglePayment $payment, PaymentChargeback $chargeback): voidThis is not a rename — read the body you already have. Refunds and chargebacks are now delivered per occurrence, and the value object says which one. A payment can carry several partial refunds, so an implementation written against the old one-argument hook almost certainly books the FULL amount back on the first refund that arrives: that was the only thing it could do, and it is wrong for every partial. Use
$refund->amount(and$chargeback->amount), and if the decision depends on the total refunded so far, sum the completed entries inprovider_refund_detailsrather than trusting one occurrence to represent the whole.Two rules the value objects carry:
- Key your idempotency on
$refund->id/$chargeback->id. Webhook redelivery makes at-least-once the only honest guarantee. - Check
isBookable()before you compute withamount. It is the provider's own string, passed through unparsed, and a corrupt or non-Mollie snapshot can yield''or scientific notation — which throws in bcmath and silently becomes0.00in a decimal cast.
Only completed refunds are delivered (provider status
refunded); pending, failed and canceled ones are not. The full contract is in the changelog and in theIsPayabledocblock itself. - Key your idempotency on
-
In-place migration:
billable_subscriptions.activation_source. Development databases:php artisan migrate:fresh. A database with data you want to keep (MySQL syntax; adjust the prefix, dropAFTERon drivers without it):ALTER TABLE larsasub_billable_subscriptions ADD activation_source VARCHAR(48) NULL AFTER deactivation_reason;Existing rows keep a null source — the origin of historical activations cannot be reconstructed reliably, and nothing queries null sources.
-
In-place migration:
billable_subscriptions.started_processed_at(the once-only marker for the newBillableSubscriptionStartedEvent):ALTER TABLE larsasub_billable_subscriptions ADD started_processed_at TIMESTAMP NULL AFTER renewal_announced_at; CREATE INDEX billsub_started_scan_idx ON larsasub_billable_subscriptions (started_processed_at, start_date);Backfill is required on databases with data. Fresh databases seed the marker at creation/activation, but existing rows all have it null — the first
larsasub:detect-started-subscriptionsrun would process every historical subscription whose start date has passed, dispatching a Started event (with mails on any host listener that sends them) for each one that was not deactivated before its start day (those only get the marker):UPDATE larsasub_billable_subscriptions SET started_processed_at = activated_at WHERE started_processed_at IS NULL AND activated_at IS NOT NULL AND start_date <= CURDATE();(
CURDATE()is MySQL; on SQLite usedate('now').) -
Queued jobs serialized before this release reference the old shapes of thirteen classes, not two: the activation PR added
$sourcetoBillableSubscriptionActivatedEventand to both (user and company)SubscriptionActivatedNotifications, and the value-objects PR added a$refund/$chargebackproperty to ten more events and notifications. A payload serialized before the release carries no such key, and the constructor does not run on unserialize — so the property comes back uninitialized and the first read throwsError.Since 0.14 the nine nullable ones default on the declaration (
RecurringChargeuser/company chargeback + refund notifications,SinglePaymentcompany chargeback + refund notifications, and the three activation classes), so for those a drain is a recommendation, no longer a requirement — an old payload readsnull.A drain stays a REQUIREMENT for the four non-nullable events, which are deliberately not widened (their contract is that they always carry the occurrence they fired for):
RecurringChargeChargebackEvent/RecurringChargeRefundedEventSinglePaymentChargebackEvent/SinglePaymentRefundedEvent
Their queued listeners (
SendsNotificationListener implements ShouldQueue) are a real path, so drain the queue before deploying if any of these can be in flight.
0.12.0
-
In-place migration:
billable_subscriptions.deactivation_reason. Development databases:php artisan migrate:fresh. A database with data you want to keep (MySQL syntax; the table name assumes the defaultlarsasub.table_prefix— adjust if you changed it, and drop theAFTERclause on drivers that do not support it, e.g. SQLite):ALTER TABLE larsasub_billable_subscriptions ADD deactivation_reason VARCHAR(48) NULL AFTER activated_status;Existing deactivated rows keep a null reason. That is only visible for subscriptions that were deactivated by a failed first payment: those no longer surface as retryable in the dashboard until the reason is backfilled. The complete mapping from every historical status string — package code only ever wrote these five failure variants:
UPDATE larsasub_billable_subscriptions SET deactivation_reason = CASE activated_status WHEN 'Deactivated - first payment failed' THEN 'first_payment_failed' WHEN 'Deactivated - first payment canceled' THEN 'first_payment_canceled' WHEN 'Deactivated - first payment expired' THEN 'first_payment_expired' WHEN 'Deactivated - first payment creation failed' THEN 'first_payment_creation_failed' WHEN 'Deactivated - mandate charge execution failed' THEN 'mandate_charge_execution_failed' END WHERE deactivation_reason IS NULL AND activated_status IN ( 'Deactivated - first payment failed', 'Deactivated - first payment canceled', 'Deactivated - first payment expired', 'Deactivated - first payment creation failed', 'Deactivated - mandate charge execution failed' );Verify with a before/after count of the
WHEREset; other deactivation reasons (contract period completed,changed, terminations) may stay null — nothing queries them.
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.
-
standalone.middlewareis now the COMPLETE stack. It always claimed to be, butroutes/web.phpwrapped every dashboard registration inRoute::middleware(['web']), so the configured list was appended toweb. If your list containsweb— the default does — nothing changes. If you removedwebdeliberately, you now get what you asked for, and if you removed it assuming the package would add it back, your dashboards lose the session and CSRF middleware. Check:php artisan route:list --name=larsasub.my-subscription --columns=name,middlewareKeep
webunless your guard is genuinely stateless: without it the dashboards still render while every action (cancel, change, pay, save profile) answers 419. -
larsasub:install --seed --active --active-payment-methodsare skipped on an existing installation. Those flags create demo data. Re-running them on an install that already has plans produced duplicate purchasable plans and re-enabled payment methods an operator had switched off. A fresh install is unaffected; an existing one now prints why it skipped. If you deliberately used them to reseed, seed through your own seeder instead. -
barryvdh/laravel-dompdfmoved fromrequiretosuggest. If you use the built-in invoice/credit-note PDFs, require it directly BEFORE you update:composer require barryvdh/laravel-dompdfOrder matters. If dompdf is only present transitively — through this package — Composer can remove it during the very update that makes it a suggestion.
InvoicePdfServicethen throws aLarsaSubExceptionnaming the package the first time someone downloads an invoice, which is a poor moment to find out. Hosts that bind their ownInvoiceRenderer, or never enable invoicing, can leave it out — which is what the documentation promised all along.
Behavior changes that need no action, but explain a difference
-
Reverse charge now requires
invoice_type = 'business'. A customer whose profile says personal while carrying a VAT number was charged net and invoiced withreverse_charge = trueand no VAT number on the document. Those customers now pay VAT. Switching a profile away from business also clears the company name, VAT number and CoC number, so a stale number cannot outlive the type that justified it. If you have such profiles, check them before this release goes out:SELECT id, invoice_country, invoice_vat_number FROM larsasub_billables WHERE invoice_type <> 'business' AND invoice_vat_number IS NOT NULL; -
Changing to a plan whose
activeflag is off is refused. The change dialog never offered those; the endpoint accepted them. If you relied on moving subscribers onto a retired plan by id, re-activate the plan for the duration, or move them with your own code. -
A payment method a tenant has not enabled is refused on the manual- charge and single-payment start endpoints, not just hidden from the picker. In Connect mode a method now needs
tenant_usableand an activeTenantPaymentMethodrow for that tenant — the same rule the picker and the add-payment-method flow already applied.The Connect dashboard lists every tenant-usable method, enabled or not, so seeing one there does not mean it is switched on. Check the rows rather than the page:
SELECT tenant_type, tenant_id, payment_method_id, active FROM larsasub_tenant_payment_methods;There is deliberately no backfill: creating rows would silently enable methods a tenant never opted into, and that opt-in is the whole mechanism. A tenant with no active row has to enable their methods on the Connect dashboard before this release goes out. One case that was previously unchecked and now is: a
SinglePaymentcreated headlessly with a method the tenant has not enabled. -
Cost-line descriptions are capped at 191 characters and rejected above it (
SubscriptionCostService::MAX_DESCRIPTION_LENGTH). Longer labels used to be accepted and then made the line unendable, because ending it composes a refund text around the label into a 255-character column. -
Published views keep the old dashboard payload. If you published
resources/views/vendor/LarsaSubbefore this release, your copy oflayouts/standalone.blade.phpstill serialises the whole authenticated user model intowindow.LarsaSub. Re-publish it or remove the'user'key from your copy.
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.
-
Schema: one migration was edited in place.
larsasub_billablesgains a nullableanonymized_attimestamp, set by the newBillable::anonymize(). Development databases:migrate:fresh. A database with data:ALTER TABLE larsasub_billables ADD COLUMN anonymized_at TIMESTAMP NULL AFTER invoice_country; -
invoicing.enablednow defaults tofalse. Invoices are numbered, legally relevant documents, and the old default issued them with a hardcoded 21% Dutch VAT rate and whatever seller details happened to be configured — including none. If you invoice, setLARSASUB_INVOICING_ENABLED=true(or'enabled' => truein a published config). Without it the invoice routes are not registered, paid charges generate no invoice, and the on-demand endpoints answer 404. Turning it on with an emptyinvoicing.seller.vat_numbernow logs a warning and is reported bylarsasub:doctor. -
Published views move. Both view tags now publish to
resources/views/vendor/LarsaSub— the path Laravel actually readsLarsaSub::overrides from. Anything published before this release sits inresources/views/PetersDevelopment/LarsaSuband was never used by the view finder. Move the files you actually customized:mv resources/views/PetersDevelopment/LarsaSub/* resources/views/vendor/LarsaSub/Overrides you thought were live now become live — re-check them against the current package templates before deploying.
-
Broken configuration throws at boot.
Support\ConfigValidatorruns in the service provider and refuses provably-invalid configuration: a class name that does not exist or is not instantiable (billable.model,platform_model,owner_resolver,connect.tenant_context,terms.provider, every entry inlarsasub.providers), an unknown enum value (resolution_mode,connect.fee.type,invoicing.price_display,invoicing.country_display),currency.defaultoutsidecurrency.allowed, Connect enabled withoutconnect.tenant_relation, and a Connect client id without theapp_prefix. The license gate's recovery commands (config:clear,vendor:publish,larsasub:doctor, …) stay available so a cached broken config can still be repaired. Dangerous-but-legal configuration — emptypolicies.admin_roleornotifications.sales, invoicing without a seller VAT number, a developmentapp.urlin production — logs a warning once per hour per unique warning set. -
Ownership checks fail closed instead of throwing. In
resolution_mode: 'explicit',ownedByUser()and the policy ownership fallback used to surfaceBillableResolver::resolve()'s exception as a 500. They now deny. To make them work, configure the newbillable.owner_resolver: an invokable returning the current owner from your request context (null on landlord routes). Headless hosts that never hitresolve()need nothing. -
Invoices, invoice lines and credit notes are immutable. Any update other than
external_download_url(plus the singlefinalized → voidedtransition) and every delete now throwLarsaSubException. Host code that edited or deleted these rows must switch to credit notes. Query-builder mass updates still bypass model events — treat the tables as append-only. -
Notice periods are capped at one month for tenant-defined plans.
subscription.max_notice_period_months(default1) rejects longercancellation_notice_period/renewal_cancellation_notice_periodvalues in the tenant dashboard with a 422. It is one package-wide number: raising it for B2B also drops the consumer-safe default for B2C subscribers on the same installation.nulldisables the cap. Existing stored plans are not touched. -
A twelfth scheduled command.
larsasub:prune-model-logsruns daily and deleteslarsasub_model_logsrows older thanretention.model_logs_days(default 730). Set it tonullor0to keep the previous grow-forever behavior. Frequency:scheduling.prune_model_logs_frequency. -
Cost-line changes now email the subscriber. Adding, re-pricing or ending a
SubscriptionCostLinesends the new monthly amount to the user funnel. Disable withnotifications.user.subscription_cost_changed = falseif you announce amount changes yourself. -
Provider routes come from the registry.
routes/core.phpregisters the routes of the provider named bylarsasub.provider, resolved through the newlarsasub.providersmap. A published config without aproviderskey keeps working (the validator tolerates its absence), but re-publishing the config is recommended so a custom provider can be registered. -
Optional: withdrawal consent. Setting
terms.withdrawal_consent = true(only meaningful with aterms.provider) adds a second required checkbox to the bundled subscribe flow and records it as its own acceptance. Off by default; nothing changes unless you enable it.
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.
-
Schema: three migrations were edited in place.
larsasub_recurring_chargesgains three composite indexes (rc_provider_payment_charged_idx,rc_subscription_planned_idx— which replaces the barebillable_subscription_idindex — andrc_charged_planned_idx),larsasub_single_paymentsgains an index onprovider_payment_id, andlarsasub_mollie_mandatesgains a nullable, indexedlast_validated_atcolumn.On a development database,
php artisan migrate:freshand you are done.On a database with data, apply manually (table names assume the default prefix):
CREATE INDEX rc_provider_payment_charged_idx ON larsasub_recurring_charges (provider_payment_id, charged_at); CREATE INDEX rc_subscription_planned_idx ON larsasub_recurring_charges (billable_subscription_id, planned_on); DROP INDEX larsasub_recurring_charges_billable_subscription_id_index ON larsasub_recurring_charges; CREATE INDEX rc_charged_planned_idx ON larsasub_recurring_charges (charged_at, planned_on); CREATE INDEX larsasub_single_payments_provider_payment_id_index ON larsasub_single_payments (provider_payment_id); ALTER TABLE larsasub_mollie_mandates ADD last_validated_at TIMESTAMP NULL; CREATE INDEX larsasub_mollie_mandates_last_validated_at_index ON larsasub_mollie_mandates (last_validated_at); -
Connect mode now refuses when it cannot establish the tenant. A billable whose tenant does not resolve gets a thrown
PaymentProviderServiceExceptionfrom the Mollie client resolver (it used to fall back to the platform's credentials), and the subscriber dashboard endpoints answer 403 instead of showing the unscoped catalog. If you see new 403s after upgrading, checkconnect.tenant_relation— the refusal is surfacing a misconfiguration that previously leaked cross-tenant data. Simple mode and dual-billing platform billables are unaffected. -
Reconnecting a tenant to a different Mollie organization now fails with
connect_error=organization_changedinstead of silently orphaning every stored customer/mandate id. An intended switch: runphp artisan larsasub:connect-reset {tenant_type} {tenant_id}first (all subscribers must then re-authorize their mandates). -
A dead OAuth token now disconnects the account and notifies the tenant (
notifications.company.connect_connection_lost, default on — set it to false if your host handles this itself). -
Webhook routes are now throttled at 300 requests/minute per IP (
webhooks.rate_limit;nullrestores the old unthrottled behavior). -
Policies fail closed on unknown action values and on model keys missing from
larsasub.policies.models. The shipped config is complete, so out-of-the-box nothing changes — but if you published and pruned the policies config, absent keys now deny instead of allow. -
GeneralHelpers::transaction()retries deadlocks (3 attempts). If your host passes closures with non-idempotent external side effects into it, make them idempotent, move them after commit, or passattempts: 1. -
A throwing invoice renderer no longer aborts invoice/credit-note generation. The record is created and the download endpoints fall back to the package PDF; the renderer error is logged. Also, the renderer now runs after the outermost commit — do not rely on it seeing uncommitted state.
-
larsasub:validate-mandatesis no longer unbounded. Without a valid--limit/scheduling.validate_mandates_limita built-in default of 5000 per run applies; the new resume cursor guarantees full fleet coverage over successive runs either way. -
Dashboard payloads changed shape. Invoice dashboards are paginated (
invoicesnow holds one page;invoicesPaginationcarries current/last/total) and the tenant subscribers/charges dashboards cap their lists atstandalone.list_limitwith truncation metadata. If you built custom frontends on these blade payloads, account for the new keys; the bundled dashboards handle everything. Re-publish the assets:php artisan vendor:publish --tag=larsasub-assets --force.
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.
-
Schema: two migrations were edited in place (pre-1.0 policy, see the note at the top).
larsasub_credit_transactionsgainsremaining_amount, andlarsasub_invoice_sequencesgainstenant_keywhile its unique moves from the nullable morph columns to(tenant_key, type, year).On a development database,
php artisan migrate:freshand you are done.On a database with data,
migratewill not pick these up — the migration files are already recorded as run. Two manual steps (table names assume the defaultlarsasub.table_prefix), and the first one can fail on purpose:-- 1. Invoice sequences. Add the column, backfill, then the unique. ALTER TABLE larsasub_invoice_sequences ADD tenant_key VARCHAR(255) NOT NULL DEFAULT ''; UPDATE larsasub_invoice_sequences SET tenant_key = CASE WHEN tenant_id IS NULL THEN '' ELSE CONCAT(tenant_type, ':', tenant_id) END; DROP INDEX larsasub_inv_seq_tenant_type_year_unique ON larsasub_invoice_sequences; CREATE UNIQUE INDEX larsasub_inv_seq_tenant_type_year_unique ON larsasub_invoice_sequences (tenant_key, type, year);If that last statement fails, do not force it. It means duplicate sequence rows exist for one tenant-year — the bug this release fixes, sitting in your data. Look before you merge them:
SELECT tenant_key, type, year, COUNT(*), GROUP_CONCAT(id), GROUP_CONCAT(last_number) FROM larsasub_invoice_sequences GROUP BY tenant_key, type, year HAVING COUNT(*) > 1;Keep the row with the highest
last_numberand delete the others: numbering continues above every number already issued, which is what you want. You do not have two invoices carrying one number —invoice_numberhas always had its own unique constraint, so the collision surfaced as the losing invoice failing to save, not as two documents with the same reference. That is worth knowing before you go looking: what you lost was invoices that were never created, and those show up as failed charges, not as bad paperwork.-- 2. Credit. Add the column; skip the rest if nobody has a credit balance. ALTER TABLE larsasub_credit_transactions ADD remaining_amount DECIMAL(10,2) NULL; UPDATE larsasub_credit_transactions SET remaining_amount = amount WHERE remaining_amount IS NULL AND amount > 0 AND expired = 0;A
NULLhere is not neutral, which is why the backfill is not optional if you have credit in the system. The FIFO allocator filters onremaining_amount > 0, andNULL > 0is unknown — so a legacy row is invisible to deduction and keeps its full original amount forever, while expiry falls back toamountand voids all of it. That is exactly the bug this release fixes, preserved on untouched rows.The backfill above sets every open addition to its full amount, which overstates any credit that was already partly spent. Check the invariant afterwards — the sum of open remainders should equal the stored balance:
SELECT c.id, c.balance, COALESCE(SUM(t.remaining_amount), 0) AS remainders FROM larsasub_billable_credits c LEFT JOIN larsasub_credit_transactions t ON t.billable_credit_id = c.id AND t.amount > 0 AND t.expired = 0 GROUP BY c.id, c.balance HAVING c.balance <> COALESCE(SUM(t.remaining_amount), 0);Every row this returns has been spent against. Draw the difference off the soonest-expiring addition first (then oldest), which is the order the allocator itself uses. The stored
balanceis authoritative and already correct; you are only reconstructing which addition it came from. -
RecurringCharge::settleLocally()lost itswithInvoiceparameter. It is nowsettleLocally(string $reason)and always invoices. If you called it withfalse, you were relying on a charge being paid without an invoice — that is the defect this removes, because the charge stayed invoiceable and a latergenerate-missing-invoiceswould mint one for an amount that had since changed. If you need a settled charge that owes nothing, zero its amounts and let it invoice at €0, the way the non-invoiceable-subscription branch now does. -
Invoice::creditNote()now returns the most recent credit note, not the credit note. An invoice can carry several once a partial refund is possible. The relation islatestOfMany(), so it is deterministic rather than an arbitrary row, but if you were treating it as "the amount credited against this invoice", switch tocreditNotes()and sum — otherwise you read one note where there may be three. -
Partial refunds no longer void the whole invoice. A refund now produces a credit note for the amount actually refunded, VAT split pro rata, and the invoice stays
finalizeduntil it is fully credited. Nothing to do, but two things change under you: an invoice can now have multiple credit notes, and an invoice with a refund against it is no longer necessarilyvoid. If you report on "credited" by counting void invoices, that number moves. Chargebacks still void in full. -
Credit that was applied to a charge now comes back when that charge is deleted unpaid, or when a non-invoiceable subscription's charge is settled at €0. Previously it was debited from the ledger and never returned. Balances go up for anyone this happened to; the credit returns as fresh non-expiring credit rather than under its original expiry, because the original expiry window is not recoverable once the addition has been drawn down.
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.
-
Zero-rated customers are now charged the VAT-exclusive amount. Before this release they were charged VAT-inclusive and invoiced net, so the invoice total did not match what was debited. The resolution is collect net: invoice total now equals the amount collected, and the amount collected goes down for every reverse-charge and non-EU subscriber.
Applies only if you have
invoicing.enabledandprices_include_vaton. Otherwise nothing changes.Two consequences worth planning for rather than discovering:
- Recurring amounts change for existing subscribers. A €30 gross plan
collects €24.79 from a zero-rated customer from the next charge onward —
that figure is at 21% VAT; the amount is derived per invoice from its own
vat_rate, so another rate gives another number. Under SEPA a decrease needs no fresh mandate and no new pre-notification, but your own support copy may still owe an explanation. - Invoices already issued are not recalculated. The mismatch on
historical invoices stays; this release stops it from growing. If you want
to make good on the difference, the affected lines are the ones for
customers whose
invoice_countryzero-rates them.
SinglePaymentis not included: those amounts are host-supplied and are still collected and invoiced gross. Collected still equals invoiced, so nothing is inconsistent — it simply differs from subscriptions, and changing it means altering an amount you passed in explicitly. - Recurring amounts change for existing subscribers. A €30 gross plan
collects €24.79 from a zero-rated customer from the next charge onward —
that figure is at 21% VAT; the amount is derived per invoice from its own
-
The introduction price is now applied to subscriptions without cost lines. It was silently ignored: those subscriptions collected the plan's full
price_amountfrom day one. A €30 plan with a €15 intro price charged €30. Post-upgrade it charges €15 for the intro duration, as it always should have.Check whether you compensated for this somewhere. If a host-side credit, a manual refund routine or a support script existed to correct the over-billing, it now double-discounts. That workaround is the thing to remove as part of this upgrade — not the fix.
-
A €0-base subscription carrying a paid cost line starts being charged. Chargeability keyed on
price_amountalone, so those subscriptions were never planned at all whilemonthlyTotal()cheerfully reported the amount owed. They now bill what the cost lines add up to.Whether this can affect you depends on how the subscription was created. The plan-template guard ("a charged subscription must have a price") blocks a €0 base at plan level, so a plan-derived subscription cannot be in this state.
BillableSubscriptioncarries no such guard, so an instance built directly — or through a future modules-only plan — can be. Before you deploy, check for them:PetersDevelopment\LarsaSub\Models\BillableSubscription::query() ->where('is_charged', true) ->whereRaw('CAST(price_amount AS DECIMAL(10,2)) = 0') ->has('costLines') ->count();Anything this returns starts collecting money at the next run. That is the correct behaviour and the reason for the fix, but it should not be the first you hear of it. A cost line of
0.00does not count — a free module leaves nothing to bill and does not make a €0-base subscription chargeable. -
A plan change on a month-end anchor now issues the prorated credit it owed.
calculateProratedCreditstill reconstructed the current period withfloor(diffInMonths)+addMonthsPreservingDay— the reconstruction 0.6.0 replaced everywhere else. On a capped month-end anchor (anchored 31 Aug, changed on 30 Sep)diffInMonthsgives 0.968,floorgives 0, the period came out as[31 Aug, 30 Sep)and was read as already past: €0 credit on exactly the day a full new month had been paid for.Nothing to do, but the amounts move in the customer's favour: a change on those dates now credits the unused remainder instead of nothing. Credits already issued are not recalculated.
-
fee_typeandfee_valueare no longer accepted byTenantSubscriptionRequest. That request is submitted by the tenant who pays the platform's application fee, and accepting those fields let a tenantPUT fee_value: 0on their own plan and zero the platform's revenue on it. They are dropped from the validation rules, so they never reachvalidated().If a tenant-facing form of yours submits them, they are now ignored silently — Laravel drops unvalidated keys rather than erroring. Both fields stay
$fillable, so platform-side admin UIs (the Nova and Filament companions) are unaffected. Fee overrides belong there. -
Mollie Connect OAuth flows started before the upgrade will fail at the callback. The cached
stateentry now carriesuser_keyand the callback fails closed when it does not match; an entry written by the old code has nouser_key, so it cannot match. The entry lives 30 minutes, so the window is the half hour after you deploy, and the remedy is to start the linking flow again. There is no way to make an old entry pass without also accepting one forged by an attacker, which is the CSRF this closes. -
Three new cost-line events are dispatched:
SubscriptionCostLineAddedEvent,SubscriptionCostLineChangedEvent,SubscriptionCostLineEndedEvent. Nothing to do — the package ships no listeners and nothing changes if you ignore them. They exist so you can send a pre-notification when a cost line changes a subscriber's monthly amount, which the one-time activation notice does not cover. Each carries the resulting steady-state monthly total, so a listener does not have to recompute it.
0.6.0
-
Mollie Connect now requires a declared Platform tier. New config key
larsasub.license.tier(LARSASUB_LICENSE_TIER), defaulting tosingle.If you do not use Mollie Connect, nothing changes and there is nothing to do.
larsasub.connect.enableddefaults tofalse, and with Connect off the gate never evaluates the tier — no boot is refused and no command is blocked, on any tier. (larsasub:licenseandlarsasub:installstill read the value to display it; that is all that reads it.)If you do use Connect, set the tier before you upgrade:
LARSASUB_LICENSE_TIER=platformWithout it the package throws
LicenseTierExceptionduring boot. Be clear about what that means: the exception comes from a service provider, so it takes down the whole application, not just the billing pages. It is not a soft landing and it should not reach you mid-deploy as a surprise.If your configuration is cached, the env line alone is not enough.
config:cachefreezesenv()at cache time, so editing.envchanges nothing until you runphp artisan config:clear(orconfig:cacheagain). Both are on the allowlist below precisely so that route stays open.A short allowlist of console commands keeps working while the gate is closed, so an installation whose broken config is already cached can still be repaired:
config:clear,config:cache,cache:clear,optimize:clear,package:discover,vendor:publish,larsasub:install,larsasub:license,listandhelp. Everything else —migrate,optimize,queue:work,schedule:run, the billing commands — is refused, because a deploy or a billing run on a tier that does not cover Connect is precisely what this stops.schedule:runbeing refused stops your whole schedule, not just ours. Laravel runs every due task through that one command, so while the gate is closed none of your own scheduled tasks run either. That is the same all-or-nothing shape as the web outage above, and it is deliberate: the alternative is letting the scheduler boot and relying on each command to refuse itself, which leaves any host-written command that calls LarsaSub billing services free to charge through Connect. Failing closed where money moves is the trade this makes. Note that a stopped scheduler is quieter than a stopped website — if you monitor one thing after this upgrade, monitor that.Run
php artisan larsasub:licenseto see what an installation declares.This is a declaration, not a license check. Nothing is verified, signed or transmitted; the package still never contacts larsasub.eu. The point is that running Connect without a Platform license should be a deliberate act rather than a switch someone flipped without ever meeting the question. Where the tier line sits is set out at https://larsasub.eu/pricing — in short: your own Mollie account is Unlimited, however many applications you spread it over; other people's Mollie accounts is Platform.