Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Cost lines
Cost lines add recurring amounts on top of a BillableSubscription's base price
— per-month toggleable modules, add-ons, seats. Your application owns which
lines exist (it has the module catalog); LarsaSub owns the billing math:
proration, catch-up, credit for prepaid days, the charge breakdown and the
subscriber notice. Read this page when you sell anything on top of a plan.
- The model
- The service
- Reconciling a whole set
- Notifying the subscriber
- Proration
- How a charge is composed
- Quoting the first charge
- Description length
- Moving the start date
- Subscription change
- Relations and scopes
The model
Models\SubscriptionCostLine, table larsasub_subscription_cost_lines. Rows
are soft-deleted: a line that never became active and is removed again stays in
the table with deleted_at set.
| Property | Description |
|---|---|
billable_subscription_id |
Owning subscription instance |
key |
Your identifier, e.g. module:agenda — one active line per key |
description |
Shown on the charge breakdown, the invoice line and the credit ledger |
amount |
Monthly amount, as listed (before any VAT conversion) |
starts_on |
First active day (inclusive) |
ends_on |
Last active day (inclusive); null = open-ended |
billed_through |
Last day already covered by a generated charge — drives proration catch-up |
valued_from |
First day of the stretch billed_through closes that money was actually valued for — the lower bound of any refund. Normally starts_on; an opening charge that clamps its window (see Proration) puts the two apart. null = nothing valued yet, or a row older than the column (read as starts_on). Read it through $line->valuedFrom(), never as the raw attribute |
Never write these rows directly.
billed_throughandvalued_fromare bookkeeping that the refund maths depends on; mutating a line outside the service is how you end up refunding days that were never paid.
The service
Service\SubscriptionCostService is the only way to change cost lines.
Resolve it from the container: it is bound as a singleton, because the
batch scope lives on the instance.
Adding, ending and repricing a line
use PetersDevelopment\LarsaSub\Service\SubscriptionCostService;
$service = app(SubscriptionCostService::class);
// Toggle a module on: active from today, prorated onto the next charge
$line = $service->addLine($billableSubscription, 'module:agenda', 'Module: Agenda', 7.50);
// Toggle it off: active through today; prepaid days after today come back as credit
$service->endLine($billableSubscription, 'module:agenda');
// Price change: the old rate runs through today, the new one from tomorrow
$service->changeLineAmount($billableSubscription, 'module:agenda', 10.00);
What each call checks and does:
addLine()refuses a negative amount, a description above 191 characters, and a key that already has a line open (or ending on or after the new start) — each with aLarsaSubException. A0.00amount is allowed. The start date is clamped up to the subscription'sstart_date: a line is never active before its subscription.endLine($sub, $key, $endsOn = null)ends the open line for the key on$endsOn(today by default), inclusive. A line that has not started yet (starts_onin the future) has never billed, so it is removed instead of ended. Throws when the key has no open line.changeLineAmount($sub, $key, $newAmount, $effectiveFrom = null, $description = null)ends the current line the day before$effectiveFrom(tomorrow by default) and starts a successor at the new amount, so past days stay billed at the old rate. A line that has not started yet is updated in place.
Each call fires its cost-line event and counts as one act for the
subscriber notice. For a whole set of toggles,
reconcileLines() is the declarative alternative.
endAllLines() and rollbackChargeLines() are public too, but LarsaSub calls
them itself wherever a charge stops being collectable — a subscription change,
an abandoned checkout, a charge settled at €0 — so you should not need them.
endAllLines() fires no cost-line events and sends no notice.
Reading the totals
$service->activeLines($billableSubscription); // lines active today
$service->periodTotal($billableSubscription); // the steady-state DEBIT per billing period
$service->monthlyTotal($billableSubscription); // the month-normalized view (same number on monthly plans)
All three take an optional date as their second argument. Pass the
subscription's start_date on a deferred subscription, where the lines are
pending until then and "today" would list none of them.
periodTotal() and monthlyTotal() take a third argument to sum over a
different base price than the plan's own — what an introduction period costs
with the add-ons. It runs through the same per-component VAT conversion the
charge uses, which is why it is a parameter rather than an addition at the call
site.
periodTotal()is the DEBIT, not the sum of the listed amounts. With invoicing on it applies the same VAT conversion the charge does, so on a VAT-exclusive install it returns €36,30 where the plan and its lines list €30,00, and for a zero-rated customer on inclusive prices it returns less than the listed price. On a monthly planmonthlyTotal()is the same number; on quarterly/yearly plans it is the normalized per-month VIEW (periodTotal / interval) — fine for display next to the period figure, never the amount of any collection. A line's ownamountstays the listed figure. If you print both next to each other, say which is which — the package's own mails do (Notifications).
Starting a line on another day
addLine() takes an optional $startsOn — use it when the line should bill from
a date other than today:
$service->addLine($sub, 'module:agenda', 'Module: Agenda', 7.50, startsOn: $sub->start_date);
That is the correct call after
BillableSubscriptionService::createDeferredMandateSubscription(): pass the
stored, normalized start_date, since a past start date clamps to today
(unless you imported with paidThrough — see below).
Importing a line that was billed elsewhere
$billedThrough is the import counterpart. A fresh line has no
billed_through, which means "never billed", so the first charge bills every
day since $startsOn in one catch-up — correct for a module switched on today,
wrong for one that has been running in your previous system for months:
$service->addLine($sub, 'module:agenda', 'Module: Agenda', 7.50,
startsOn: '2025-09-01', // when the module really started
billedThrough: '2026-04-11'); // billed elsewhere through this day
Billing then resumes the day after, and the line announces nothing — the
subscriber has been paying it all along, so no subscription_cost_changed
notice goes out (the added event still fires). $billedThrough must not
predate the line's effective (clamped) start date; that throws.
It is a statement that the money was received: ending or repricing the line before that date refunds the prepaid days as credit, so a date set too far ahead mints credit for money nobody collected. See Migrating subscribers in.
Quarterly and yearly plans
On plans with billing_interval_months > 1 a line's amount stays the
monthly figure — your app never needs to know the interval of the plan a
line sits on. A full billing period bills interval × the monthly amount (a €10
module on a yearly plan adds €120 to the yearly charge), partial periods
prorate per calendar day within their month slice, and ending a billed line
refunds through the same math.
Reconciling a whole set
For toggleable modules the declarative call is usually the right one: compute the desired set from your own state and let LarsaSub work out the difference.
$service->reconcileLines($billableSubscription, [
'module:agenda' => ['description' => 'Module: Agenda', 'amount' => 7.50],
'module:mail' => ['description' => 'Module: Mail', 'amount' => 4.95],
], ownedPrefixes: ['module:']);
After the call, exactly the desired lines are open for the owned prefixes:
- It is idempotent, and it only touches keys matching
$ownedPrefixes, so lines you manage yourself survive. A desired key outside those prefixes throws. - It ends what is no longer desired, reprices what changed (old rate through today, new rate from tomorrow) and relabels in place when only the description changed.
- A line that has not started yet is adjusted or removed in place.
- A key ended today and desired again starts the next day, so no day is billed twice.
The whole reconcile is one act: one notice, one total.
Notifying the subscriber
A cost line changes the amount the activation notice promised, and the SEPA
rulebook's per-series exemption only covers an unchanged amount — so the
subscriber gets a notice: SubscriptionCostChangedNotification
(notifications.user.subscription_cost_changed, on by default).
One notice per act
SubscriptionCostChangedEvent carries a Support\CostLineChangeSet: every
change plus the settled billing-period total, computed after the last
mutation. The property is still called monthlyTotal — frozen wire format;
->intervalMonths says which period it covers. reconcileLines() is one act,
and so is every standalone addLine()/endLine()/changeLineAmount() call.
When one user action causes several calls, wrap them:
$service->batch(function (SubscriptionCostService $service) use ($tenant) {
$this->syncModules($tenant); // may reconcile
$this->syncSeats($tenant); // may reconcile again
}); // → one notice, one total
Batches nest: an inner batch joins the outer one. There is deliberately no automatic per-request batching: the flush would have to hang off the request lifecycle, and in a queue worker that fires at worker shutdown — hours late, or never.
What is not announced
Three cases stay silent, because none of them is news:
- A subscription's opening composition — its first set of cost lines: the
onboarding selection, or the lines your app re-adds after a plan change. The
activation notice states the resulting amount (it lists the add-ons and the
billing-period total), so there is nothing to correct. Three conditions must
hold together: the change takes effect on or before
start_date, no charge has ever gone out for the subscription, and it carried no other cost line when the act started. Without the last one, every toggle during a long trial would be silent: a deferred subscription bills nothing until its start date, and every line added before then is clamped to exactly that date. - An imported line — one added with
billedThrough(see Importing a line). The subscriber has been paying it all along, so a "your amount is changing" mail in the middle of a migration would be both wrong and alarming. - Anything inside
$service->silently(...)— your escape hatch for data migrations and bulk relabels. Per change, so a silent scope nested in a batch silences only its own mutations.
A relabel is never announced either: it changes nothing the subscriber pays.
The upcoming-charge notice is not a fallback.
charge_announcedonly fires for charges flaggednotifyand not announced yet, which no generated charge is: the planner's charges carrynotify = false, and a first payment is marked announced when it is created. That is by design — a subscription is a fixed series, announced once at activation. So nothing downstream repeats a change withheld here, which is why the opening-set rule is this narrow. One gap remains by design: a subscription that starts with no cost lines and gains its first one before its first charge is silent, because that set is indistinguishable from an onboarding selection. The designed route for that shape is thebeforeFirstChargehook: lines added there ARE the opening set, the checkout amount states them and the activation mail repeats them. If your app adds a first line outside that seam and you want a notice for it, send it yourself from the event.
The events fire in every case; only the notice is withheld. Hosts sending their
own copy check $event->changes->shouldAnnounce() — see Events.
Proration
The model is per calendar day, inclusive of the first day, and it values in anniversary-month slices whatever the plan's billing interval is — a yearly charge period simply contains twelve of them:
- A line active for a full charge period bills flat: the monthly
amounton a monthly plan,billing_interval_months × amounton an interval plan (each month slice flat). Steady state is therefore exact, with no rounding drift. - Partial month slices bill
amount × activeDays / sliceDaysagainst their own month's length. - A line added mid-period is caught up on the next charge: the unbilled
window since
starts_onis valued against the month slices it falls in.
Credit when a line ends
Ending a line before its billed_through refunds the overbilled days as
credit (CreditTransaction::TYPE_COST_LINE_ADJUSTMENT), which the next
charge consumes automatically (Credit). The credit is what was
collected for those days, read from the charge that billed them: VAT that
charge added is included, VAT it removed for a zero-rated customer is not. The
charge does not record the rate it removed, so that one is the subscription's
rate when the line ends: a plan rate
change in between moves this credit.
It covers only the money that charge kept:
- Money that went back on the charge — a refund (pending ones included), a chargeback that still stands, credit given back from it — less what a paid chargeback follow-up brought back in, is set against the subscription's base price first and only the rest against the cost lines, proportionally. A module ended after its period's payment was charged back or refunded in full therefore gives no credit: the money already went back once.
- What is still owed for those days — an open chargeback follow-up, a collection that failed and was not paid since, a payment the customer started by hand, or a first payment not made yet — comes off the credit, never below zero. A customer who pays the debt later does not get that part back. A collection still planned or under way does not count as owed.
- The share is taken when the line ends: money that goes back later is not set against a credit already given.
Every reduction is logged on the subscription (cost-line-refund-reduced,
cost-line-refund-withheld-unpaid, cost-line-refund-withheld-unreadable).
The refund never reaches further back than valued_from. An opening charge (a
first payment, and above all a late retry of one) clamps its window to its own
period start and writes the days before it off, so billed_through covers days
nobody was ever charged for; valued_from is what keeps the refund from
handing those days back.
A line that a clamped charge marked before valued_from existed keeps
null, which reads as starts_on — so it goes on refunding across the gap
until php artisan larsasub:backfill-valued-from reconstructs its window from
the charge snapshots. That command never guesses: what it cannot prove it
leaves alone and lists, with a reason per line. See
UPGRADE.md.
Charges that never collect
A charge that never collects resets billed_through and valued_from
through rollbackChargeLines(), so nothing is refunded that was never paid.
That covers a planned charge deleted during a subscription change, a charge kept
but settled at €0 because the subscription stopped being chargeable, and a
first payment that ends terminally (below).
How a charge is composed
RecurringChargeService::planChargeForCurrentMonth() aggregates the base price
and the cost lines through SubscriptionCostService::calculateChargeAmount().
When the charge carries at least one cost line, it stores the composition on
RecurringCharge->amount_breakdown and advances each line's billed_through
(and, where the window does not continue the previous one, valued_from) —
all in the same transaction as the charge itself.
That happens on the collection day: larsasub:plan-charges plans a period's
charge on the period's first day, not ahead. So a line added before that day is
in the charge — with its catch-up for the days since it started — and a line
ended per the last day of the running period is not, with nothing to credit
back. Only a change made on the collection day after the nightly run misses the
charge: a line ended then is credited by the rule above, a line added then is
caught up by the next charge.
generateFirstSubscriptionPayment() goes through the same steps, so the
checkout that starts a subscription collects period 1 in full, modules
included, and its invoice itemises them. Two consequences:
- Lines have to exist before that call to be collected by it — use the
beforeFirstChargehook. - A first payment that ends terminally (failed, expired, abandoned checkout)
rolls its
billed_throughback, in the same transaction that deactivates the subscription. Do not mutate cost lines while a first payment is still outstanding. Ending a line then gives no credit — the unpaid first payment counts as owed — but it movesbilled_throughout from under that rollback, so the rollback no longer reaches the line and a retried first payment does not collect its days.
The first payment never values a cost-line day before its own period start. The days before it are written off permanently (and logged as a warning) — which is what keeps a checkout retried months after it was abandoned from billing modules for periods the subscription was never active in.
A subscription that is never charged (is_charged = false, which a €0 plan
always is) gets a €0 first charge that ignores its cost lines entirely — the
planner skips such subscriptions forever, so collecting module money once and
never again would be the worst of both worlds. The rows stay and are inert; the
package logs a warning.
The stored breakdown is a plain array, one entry per row, base price first and cost lines sorted by key:
[
['type' => 'subscription', 'key' => null, 'description' => 'Basic Plan',
'amount' => 30.00, 'billed_from' => '2026-08-01', 'billed_until' => '2026-08-31'],
['type' => 'cost_line', 'key' => 'module:agenda', 'description' => 'Module: Agenda',
'amount' => 4.84, 'monthly_amount' => 7.50,
'billed_from' => '2026-08-12', 'billed_until' => '2026-08-31',
'previous_billed_through' => null, 'previous_valued_from' => null],
]
amount is what the row collects, after any VAT conversion; monthly_amount
is the line's listed monthly amount. The two previous_* keys are the
rollback position and are bookkeeping, not display data. When the charge is
paid, InvoiceService::createForCharge() turns each entry into an
InvoiceLine with its billed period — see Invoicing and VAT.
Quoting the first charge
A checkout screen has to name the amount it is about to collect, before a
BillableSubscription exists. SubscriptionCostService::quoteFirstCharge() is
that answer:
use PetersDevelopment\LarsaSub\Service\SubscriptionCostService;
use PetersDevelopment\LarsaSub\Support\VatProfile;
$quote = app(SubscriptionCostService::class)->quoteFirstCharge(
plan: $plan, // €9,95 per month, on an install with
// invoicing.prices_include_vat = false
owner: $tenant, // optional; read-only, no registration is created
prospectiveLines: [
['key' => 'module:agenda', 'description' => 'Module: Agenda', 'amount' => 1.99],
['key' => 'module:facturen', 'description' => 'Module: Facturen', 'amount' => 1.99],
],
startDate: now()->addWeeks(2), // omit for today, or pass a FUTURE date for a
// trial; a past date is refused (see below)
profile: VatProfile::personal('NL'),
);
$quote->total; // 16.86 — what will be collected: gross(9,95) = 12,04
// plus 2 × gross(1,99) = 2 × 2,41
$quote->lines; // the rows, in the order the invoice will print them
$quote->periodStart; // 'Y-m-d' — the start date, snapped to its billing period
$quote->periodEnd; // 'Y-m-d' — the last day that period covers
It runs the same private core the real charge runs through, over an unsaved
subscription built from the plan and unsaved cost lines built from
prospectiveLines. There is no second calculation to keep in step.
No side effects at all: no writes, no events, no logs, no provider call,
no billed_through movement, and no Billable registration — the owner is
resolved with BillableResolver::getBillable(), never the creating variant.
A start date in the PAST is refused with a LarsaSubException — today and
any future date are fine, and a future one is the trial shape. A past start is
the import shape: a subscription someone else is already collecting, where
what is due now depends on what that other party covered, and the quote has no
input for it. The entry point that does is
createSubscription(paidThrough: …) / createDeferredMandateSubscription(paidThrough: …)
— see Importing a subscription.
Every prospective line needs key, description and a numeric amount
(starts_on and billed_through are optional) and goes through the same
guards addLine() applies; a duplicate key is refused too. A missing or
non-numeric value is refused with a LarsaSubException, not defaulted: a
typo that silently becomes a €0,00 row belongs least of all on the screen
whose job is naming an amount.
Print the rows, do not re-derive the total. VAT is converted per row and the rounded results are summed, because the charge total must be exactly the sum of its lines — the invoice is built from those same lines. Summing the listed amounts and converting once lands up to half a cent away per row, cumulatively:
9,95 + 5 × 1,99at 21% exclusive is €24,09 per row and €24,08 aggregated. Show$quote->lines[*]->amountand$quote->total.
The VAT profile
A VatProfile is required when invoicing is on, invoicing.prices_include_vat
is false and the owner has no stored invoice country. Without a country the
package refuses to gross a listed price up — correct for a charge (nobody is
over-debited) and fatal for a quote, which would then name €19,90 for money
that is collected as €24,09. The quote throws
Exceptions\VatProfileRequiredException (a LarsaSubException) instead of
answering low; catch it to ask the customer for their country. A plan that
collects nothing is quoted without a country.
With inclusive prices a profile-less quote is safe (at worst it misses a zero
rate, so it can only be too high) and reports VatBasis::Assumed. An owner
whose Billable already carries an invoice country needs no profile at all —
that record IS what the charge will read.
A profile you pass WINS over the one already stored on the
Billable— and that hands you an obligation. The quote answers with the profile you state, because a checkout wizard collects the customer's country and VAT number in order to buy under them. The charge does not: it reads the profile stored on theBillableat the moment it runs and takes no profile parameter.So if you pass a profile for an owner that already has a
Billable, you must persist that profile before you start the charge. Skip it and the two disagree in exactly the way this API exists to prevent: a customer stored as NL personal, quoted at €19,90 under a Belgian reverse charge, and debited €24,09 because the stored record still said NL.Passing no profile for an owner that has one is always safe — the quote then reads the same record the charge will. Pass one only when you are about to change it, and assert
$quote->vatRate/->treatment/->reverseChargeagainst the profile you stored if you want that guarantee in your own tests.
$quote->vatBasis (Enums\VatBasis) says what the outcome rests on. It
has three cases because a boolean could not tell "nobody knows this customer"
from "the caller stated it" — those need opposite handling:
vatBasis |
Meaning |
|---|---|
Stored |
The customer's Billable decided it, and the charge will read that same record. Exact; no disclaimer to print. |
Supplied |
The VatProfile you passed decided it. Persist it before collecting (the box above). |
Assumed |
Nothing customer-specific was known; configuration alone decided it. With invoicing on, only reachable on inclusive prices, so the amount can only be too high — this is the state to print a "final price depends on your country" note for. ->isGuess() is true here and only here. |
In Connect mode the seller matters as much as the customer: its country
decides whether an EU customer is domestic or cross-border. The quote takes the
tenant from the owner's registration, then from the owner's tenant_relation,
then from the plan — the same chain, in the same order, that
getOrCreateBillable() will stamp onto the registration the charge reads. Pass
the owner for a tenant-scoped sale, also when the plan is a global one: a
global plan names no seller, and without an owner the quote falls back to the
platform's invoicing.seller.country.
With VAT-number validation on,
$quote->vatNumberStatus says when the reverse charge hinges on the VIES check.
A profile carries a country, not a language. Frozen money text (the plan name on the base row) is generated in the billable's locale. When you pass a profile for a known owner the quote takes that locale from the owner / registration anyway, so the quoted plan name matches what the charge and the invoice freeze. With no owner at all there is nothing to take it from and the rows render in the configured default content locale.
The VAT outcome travels in the DTO — vatRate (effective, after the country
rules), treatment, reverseCharge, and a Enums\CollectionNotice from the
same source the subscription mails use, so a quote screen and the activation
mail cannot label one sale differently. netTotal / vatAmount (and
ChargeQuoteLine::$netAmount) are filled only under a gross notice that
actually moved the amount on a collectible plan: under reverse charge the
listed sum sits above the debit, and that subtraction would print a negative
VAT beside a zero rate. One gate decides the rows, the totals and the label
together, so they can never contradict each other.
steadyStatePeriodTotal is the per-period debit once everything runs: the
catalogue price plus every prospective line at its full monthly amount —
including one whose starts_on falls after period 1, which therefore has no
row in lines. It differs from total while an introduction price applies or
a line is prorated in period 1.
The flags
Three flags say the total is not the whole story. Read them before printing it:
| Flag | Meaning |
|---|---|
uncollectible |
The plan collects nothing (a €0 price is always is_charged = false). total is 0.00 and the rows are what would not be billed — show them, never their sum. Nothing is collected, so nothing is labelled or split either: collectionNotice, netTotal, vatAmount and every row's netAmount are null. |
creationRefused |
subscription.free_plan_mandate = 'refuse' applies to this plan and this subscriber, so createSubscription() will throw instead of creating anything. Do not render a checkout button. It is answered for the checkout path: the config asks whether the subscriber already has a usable mandate, so a quote for a prospect (no owner, or an owner with no registration) reads as refused — which is what the subscribe flow will do. A mandate-backed creation is never refused. |
linesIncomplete |
A subscription.before_first_charge hook is configured and you passed no prospectiveLines. That hook runs inside the creating transaction on a stored subscription and cannot be simulated, so whatever it adds is missing here. Pass the same lines the hook will add — the bundled subscription dashboard takes them from the hook itself when it implements Contracts\ProspectiveCostLines (Configuration), and shows no subscribe button when it does not. |
$quote->isComplete() is all three at once.
What the quote does not answer
This answers "what does starting this plan cost now". It is deliberately not:
- "What is my next collection?" on a running subscription —
billed_throughis genuinely relevant there, andBillableSubscription::getNextBillingDate()already gives the date. CallingquoteFirstCharge()after the subscription exists is worse than useless: the first charge has already moved every line'sbilled_throughto the period end. - "What does switching plans cost?" — that needs the prorated credit from a different path.
- "What is left after my credit?" — credit is applied at collection time and depends on the balance at that moment.
Description length
Descriptions are capped at SubscriptionCostService::MAX_DESCRIPTION_LENGTH
(191) and rejected with a LarsaSubException above it — validated rather
than truncated, because the label reaches the customer's invoice line and
credit ledger.
The cap sits below the column width on purpose: ending a billed line composes a
translated refund text around the description into the credit ledger
(credit_transactions.description, 255 characters), and that text has to fit.
A description carried forward by changeLineAmount() from an older line is
fitted rather than rejected.
Cost-line descriptions are app-owned, and language matters. Supply them in the billable's locale. The base breakdown entry is generated in that locale, so a viewer-locale cost line produces a mixed-language invoice. See Multilingual plan content.
Moving the subscription's start date
A line added before a subscription starts is clamped up to its start_date —
"never active before the subscription itself starts". That clamp is what says
this line runs from day one, and it means start_date and starts_on are
one invariant rather than two independent dates.
So do not write start_date yourself. It throws:
$subscription->update(['start_date' => $earlier]);
// LarsaSubException: The start date cannot move to 2026-03-01: 2 open cost
// line(s) (module:agenda, module:facturen) would fall outside billing or
// begin before the start …
Use the service, which moves the lines with the date in one transaction:
app(BillableSubscriptionService::class)->moveStartDate($subscription, '2026-03-01');
Moving the date without the lines costs money in both directions, and leaves no trace on the invoice. On a €9,95 plan with two €4,95 modules at 21% VAT (€24,02 due), bringing the date forward leaves the modules a month out and collects only the plan price, €12,04; pushing it back catches each module up for a period nobody subscribed to, €36,00.
What moveStartDate() does and does not touch:
- Moves open lines that sat on the old start date, plus any that would now begin before the new one.
- Leaves alone a line that deliberately starts later — a real "module starts next month" is not dragged forward.
- Refuses outright on a subscription that already has a charge — any
charge, paid or not. Billing periods are derived from
start_dateand nothing records which days a charge covered, so a moved date can hand the planner a window that overlaps days already charged, and charge the base price again. Change the cost lines instead:endLine()the ones that are wrong andaddLine()them again from the right date. The model guard refuses the same write. - Refuses when a line that would have to move has already been billed. Its invoice history says it was billed from a date that would now precede its own start, and there is no silently correct fix either.
- Notifies nobody. No amount changes, only the date they start running on. The act is logged on the subscription and on each moved line.
Both refusals throw a BillableSubscriptionServiceException. A query-builder
mass update (BillableSubscription::query()->update(...)) dispatches no model
events and so bypasses the guard, as it does everywhere in this package.
larsasub:doctor lists the lines such a write left behind.
Subscription change
BillableSubscriptionService::changeSubscription() rolls back cost-line
bookkeeping for planned-but-never-executed charges, then ends all lines per
yesterday. Prepaid days are refunded as credit and applied to the new
subscription's first charge.
Cost lines do not transfer to the new subscription. Your app owns the module state, so your app re-adds them — and where it re-adds them decides when the money arrives:
| Where | Result |
|---|---|
The beforeFirstCharge hook on changeSubscription() |
The change charge collects the lines from day one, and the prorated credit settles over the full amount. |
A BillableSubscriptionChangedEvent listener |
Still supported; the lines are caught up on the next charge instead. |
Never both: the hook fires on the change path too, so a listener doing the same work trips the same-key overlap guard — or, with different keys, bills the modules twice. See the adoption contract in configuration.
Relations and scopes
$billableSubscription->costLines; // HasMany
$line->billableSubscription; // BelongsTo
SubscriptionCostLine::query()->activeOn($date)->get(); // active on that date (inclusive window)
SubscriptionCostLine::query()->forKey('module:agenda')->get();
SubscriptionCostLine::query()->isOpen()->get(); // ends_on is null
SubscriptionCostLine::query()->billable()->get(); // amount > 0 and days left to bill
$line->isActiveOn($date); // bool
$line->valuedFrom(); // CarbonImmutable: valued_from, else starts_on
Cost-line mutations dispatch SubscriptionCostLineAddedEvent,
SubscriptionCostLineChangedEvent and SubscriptionCostLineEndedEvent per
line, and SubscriptionCostChangedEvent once per act — see
Events.