LarsaSub is launching soon. Purchases and account registration open shortly.

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

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

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_through and valued_from are 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:

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 plan monthlyTotal() 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 own amount stays 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:

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 relabel is never announced either: it changes nothing the subscriber pays.

The upcoming-charge notice is not a fallback. charge_announced only fires for charges flagged notify and not announced yet, which no generated charge is: the planner's charges carry notify = 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 the beforeFirstCharge hook: 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:

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:

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:

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,99 at 21% exclusive is €24,09 per row and €24,08 aggregated. Show $quote->lines[*]->amount and $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 the Billable at 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 / ->reverseCharge against 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:

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:

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.