Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Migrating existing subscribers into LarsaSub
Most apps that install a billing package already have paying customers. Moving them in is not a data-entry job: the subscriptions you import have a past, and the package has to be told where that past ends — otherwise the first scheduled run collects a period somebody already paid for.
That failure is quiet. The amount is right, the period is right, the mandate is right; the only thing wrong is that it happened at all, and you find out from the customer.
This page covers the import of a running subscription. Historical invoices, migrating in blocks and dry-running a month are not supported yet; see What this page does not solve.
- The one thing you must get right
- Importing a subscription
- Importing cost lines
- Adopting existing mandates
- A dry run you can do today
- What this page does not solve
The one thing you must get right
Two dates, and they are not the same date:
| What it is | Where it goes | |
|---|---|---|
| Start date | When the contract really started, back in the old system | start_date — keep it in the past |
| Paid through | The last day the previous system collected for | paidThrough, stored as first_charge_on = the day after |
start_date anchors the billing day and the period math: a subscription
that started on the 12th keeps charging on the 12th. Move it to the import date
and every customer's billing day silently moves with it.
paidThrough holds back the money, and nothing else. Until the day after
it, the planner refuses to create a charge for this subscription, and so does
generateFirstSubscriptionPayment().
Importing a subscription
The mandate already exists at the provider (see Adopting existing mandates), so the deferred entry point is the right one — it makes no provider call:
use PetersDevelopment\LarsaSub\Service\BillableSubscriptionService;
// Practice X has paid €89,00 on the 12th for years. The old system last
// collected on 12 March, covering through 11 April. We import on 20 March.
$subscription = app(BillableSubscriptionService::class)->createDeferredMandateSubscription(
owner: $practice,
subscription: $plan,
paymentMethod: $directDebit,
mandate: $mandate,
startDate: '2026-03-12', // the real, historical start of the current period
paidThrough: '2026-04-11', // what the old system already collected
);
What happens next:
- the subscription is active from the moment it is imported: a past start date means it is running, not upcoming;
- the March planner run creates nothing;
- the April run plans one charge on 12 April for €89,00, covering
[12 Apr, 12 May).
createSubscription() takes the same paidThrough parameter, for hosts that
do not go through the mandate path, with one difference that costs money if
you are not expecting it.
createSubscription() with a past paidThrough forgives the backlog
createSubscription() generates the first charge immediately, and a
paidThrough in the past does not hold that back (only a future one does: a
future first_charge_on throws). That charge is the checkout for the period
containing today, so everything between the historical start_date and
that period's start is written off:
- the plan price for those months is never collected;
- and neither are the cost lines: an opening charge never bills days from
periods the subscription was not active in, so a line carrying its own
billedThrough(or none at all) is clamped up to the period start as well. A practice imported on 20 September withstartDate: '2026-05-12'and a €7,50 module running since May collects one month of that module, not five.
Each written-off line is logged as a warning naming the line and the number of days, so the amount is at least visible in the log. The subscription itself is correct from the current period onwards.
The write-off also ends the refundability of what you declared as paid. A
cost line records the first day its billed_through actually stands for, and
the clamp moves that day up to the opening charge's period start. Ending such a
line later therefore refunds from that day, not from the billedThrough you
imported: a line stating billedThrough: '2026-02-28' whose opening charge
lands in May refunds €7,50 where €18,87 was prepaid. Keep paidThrough and
billedThrough on the period immediately before the import (the ordinary
case, where you state the period the old system just collected) and this
cannot arise.
If the backlog is money you actually want, do not use this path: import through
createDeferredMandateSubscription() (no charge is generated; the planner
starts on the next boundary) and collect what the old system missed with a
RecurringChargeService::createStandaloneCharge() of your own: one amount you
computed, with a description the customer can place on their invoice.
Pick one entry point per migration. The same imported state yields a different first amount depending on which entry point created it, because the planner does not apply the opening-charge clamp.
The clamp, and why passing paidThrough lifts it
Without paidThrough, a past $startDate is clamped up to today. That clamp is
not a nuisance: it is what stops an accidental past start date from billing its
missed period on the very first run. An import is the one case where a past
start is meant literally, and paidThrough is how you say so. Pass it, and the
real start date is kept.
If the whole remaining term was paid elsewhere
Fixed-term contracts that were paid in full are imported the same way: set
paidThrough to the last covered day. If that is beyond the subscription's
end_date, nothing is ever collected and the subscription simply runs out. That
is allowed on purpose.
Changing plans before the covered period ends
A plan change refunds the unused part of the current period as credit, but only against a paid charge in this package: calendar math over a price nobody paid here would mint credit out of nothing. An imported subscriber has no such charge for the period the old system collected, so a change during that window yields zero proration credit and the new plan is charged straight away.
That is the honest position (the money is not in this package and cannot be
returned from it), but it is invisible to the customer. If you want to honour
those days, add the credit yourself with CreditService::addCredit() before
calling changeSubscription(); you are the one who knows what the old system
collected.
What does not carry over
The introduction price counts paid charges in this package, so an imported
subscription whose intro months were consumed in the old system starts its
introduction period again. Import those on a plan without an introduction price,
or set the imported price_amount to the price the customer actually pays.
BillableSubscription.price_amount is the source of the charge amount, so a
per-customer price that differs from the plan is supported
(recipe).
Importing cost lines
A cost line that has been running for months has the same problem one level
down. billed_through is empty on a fresh line, which means "never billed",
so the first charge bills every day since starts_on in one catch-up.
use PetersDevelopment\LarsaSub\Service\SubscriptionCostService;
app(SubscriptionCostService::class)->addLine(
$subscription,
key: 'module:agenda',
description: 'Module: Agenda',
amount: 7.50,
startsOn: '2025-09-01', // when the module really started
billedThrough: '2026-04-11', // billed by the old system through this day
);
With billedThrough set, billing resumes the day after it and the first charge
carries exactly one period of the module.
An imported line also announces nothing: no subscription_cost_changed
notice goes out, because the subscriber has been paying it all along and a
"your amount is changing" mail in the middle of a migration is both wrong and
alarming. The SubscriptionCostLineAddedEvent still fires, so your own audit
trail is unaffected.
billedThrough is a statement that the money was received, not that an
invoice went out, and it works in both directions. Ending or repricing the line
before that date refunds the prepaid days as credit, which is right when the
customer really did pay through then, and mints credit out of nothing when the
date was set optimistically. Unlike the subscription-level proration, which only
refunds against a paid charge in this package, there is no second source of
truth here: your word is the evidence.
Import several lines inside one batch() so the bookkeeping settles once:
app(SubscriptionCostService::class)->batch(function ($service) use ($subscription, $imported) {
foreach ($imported as $line) {
$service->addLine($subscription, $line['key'], $line['description'], $line['amount'],
startsOn: $line['started_on'], billedThrough: '2026-04-11');
}
});
Adopting existing mandates
Mandate rows are created by the package's own setup flow, so a host that already holds mandates at Mollie has to record them itself. Getting it wrong means collecting on somebody else's mandate, or under a customer that has none. Per customer, in this order:
-
Record the Mollie customer the mandate lives under. Create the
MollieCustomerrow yourself, with the existingcst_…id, before anything else asks for one: every package path that needs a customer (getMollieCustomer(), and sorecordExistingMollieMandate()) creates a new, empty customer at Mollie when the registration has none, and a mandate recorded under that one can never be collected.use PetersDevelopment\LarsaSub\Helpers\BillableResolver; use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\Models\MollieCustomer; // The tenant the package scopes this owner's customer by: null in simple // mode and for the platform billable. $tenant = BillableResolver::deriveTenant($owner); $billable = BillableResolver::getOrCreateBillable($owner, $tenant); $customer = MollieCustomer::query()->firstOrCreate( ['billable_id' => $billable->id, 'tenant_id' => $tenant?->getKey(), 'tenant_type' => $tenant?->getMorphClass()], ['customer_id' => $existingMollieCustomerId], ); // A row that already existed keeps its own customer — after an aborted // earlier run, possibly a new, empty one. Stop and sort it out by hand. if ($customer->customer_id !== $existingMollieCustomerId) { throw new \RuntimeException("Owner {$owner->getKey()} already has Mollie customer {$customer->customer_id}."); } -
Record the mandate through the owner model, which finds that customer and writes the row idempotently:
$owner->recordExistingMollieMandate($directDebit, $existingMandateId, $iban);It is recorded as
pending: the package did not create it and has not read its status back. Recording it sends the customer the "payment method added" mail (notifications.user.payment_method_added, on by default). If your customers should not get one, switch it off where the mail is sent: the listener is queued, so in the queue worker's configuration (queue:restartafter the change), or run the import on thesyncqueue with it off. Aconfig()call in the import script does not reach the worker. -
Validate the batch. Run
larsasub:validate-mandatesright after the import. It reads each mandate's status back from Mollie, under the customer you recorded, and marks one Mollie does not know there unusable. Treat anything unusable as a customer to contact before the first collection. Withscheduling.enabledoff the command does nothing, even run by hand. Turn it on for that one process rather than for the host, whose scheduler would then start every billing command:LARSASUB_SCHEDULING_ENABLED=true php artisan larsasub:validate-mandates(with the configuration not cached).
There is no adoption command.
A dry run you can do today
There is no --dry-run on the charge planner yet. What you can do is import
into a copy of production with paidThrough set as planned, run
larsasub:plan-charges for the days you care about with the clock moved
forward, and read the resulting recurring_charges rows — date, amount and
amount_breakdown per subscription, without ever running
larsasub:execute-charges. Planning creates charges; only executing moves
money.
The planner creates a subscription's charge on its collection day, so move the clock to each collection day you want to see, not to the 1st of the month.
What this page does not solve
- Migrating in blocks (half the file here, half still on the old system).
first_charge_onholds back individual subscriptions, but there is no flag that makes the scheduler skip a group. - Historical invoices. Invoices are generated from paid charges in this package; imported history has no charges. Keep the old documents where they are, and start the invoice numbering at the right number.
- A dry run that reports without writing. See above for the workaround.