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

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

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:

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:

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:

  1. Record the Mollie customer the mandate lives under. Create the MollieCustomer row yourself, with the existing cst_… id, before anything else asks for one: every package path that needs a customer (getMollieCustomer(), and so recordExistingMollieMandate()) 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}.");
    }
    
  2. 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:restart after the change), or run the import on the sync queue with it off. A config() call in the import script does not reach the worker.

  3. Validate the batch. Run larsasub:validate-mandates right 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. With scheduling.enabled off 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