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

What you can build yourself

Ten things hosts often need that the package already supports, each with the supported entry point to use. None of these is a column trick you discover and then worry about: this page is part of the public surface, so if a recipe stops working, that is a bug.

A customer price that differs from the plan

BillableSubscription.price_amount is a snapshot of the plan's price, copied at creation, and it is what the charge amount is built from (through currentEffectivePrice()). Updating it on the instance is supported and changes what is collected from the next planned charge on:

$billableSubscription->update(['price_amount' => '79.00']);

Grandfathering a price change is exactly this: raise the plan, leave the running instances alone. The amount is per billing period (billing_interval_months), and mails and dashboards quote whatever the snapshot says.

The VAT rate is not part of that snapshot. Every charge takes the subscription's own vat_rate when it has one, else the plan's rate as it is on the collection day, so a plan's rate change reaches a grandfathered subscriber too. It changes the amount collected on exclusive prices for a customer who pays VAT, and for a zero-rated customer on inclusive prices. Set vat_rate on the instance only for a customer whose rate differs from the plan's (Per-subscription VAT rate).

"Paid through" for access control

$date = $billableSubscription->paidThrough();   // ?CarbonImmutable

The last calendar day the subscription is paid for. It is the covered period of the newest paid charge, with these rules:

Null means nothing was ever paid and nothing is on record as covered. Whether a subscriber keeps access when this date passes is your decision; the package never cuts access itself.

Refunds do not change this date. Refund a period in full and the customer keeps access until its end unless you act on it; listen for RecurringChargeRefundedEvent if a refund should end access.

Starting the invoice sequence at an existing number

Coming from another system? Seed the sequence so the first LarsaSub invoice continues your numbering instead of starting at 1:

use PetersDevelopment\LarsaSub\Models\InvoiceSequence;

InvoiceSequence::query()->create([
    'type' => 'invoice',       // or 'credit_note'
    'tenant_key' => '',        // '' outside Connect mode
    'last_number' => 421,      // the next invoice becomes 422
]);

last_number is deliberately fillable for exactly this. There is one sequence per document type, and per tenant in Connect mode, and it is never reset. In Connect mode also set tenant_type, tenant_id and tenant_key => InvoiceSequence::tenantKeyFor($tenant).

Numbers are assigned under a row lock, so seeding is safe next to live traffic. Seed only before the first LarsaSub invoice of that sequence: a create() next to an existing sequence is refused by the unique key, and lowering last_number would issue numbers twice.

The same seed works for a new installation that does not want its first invoice to read INV-000001: set last_number once, at setup.

Sending a payment link outside a browser session

Every started payment stores the provider's checkout URL on the row: provider_url on RecurringCharge and SinglePayment. Mail it, text it, print it: the URL is Mollie's own checkout and needs no session.

It is only live while the payment is open at the provider. Check is_payable (and on a charge, notPayableReason()) before offering it again, and start a fresh attempt through the manual-payment flow when it expired.

Pausing a single add-on

Pausing a cost line is ending it and adding it again later with the same key:

use PetersDevelopment\LarsaSub\Service\SubscriptionCostService;

$service = app(SubscriptionCostService::class);

$service->endLine($subscription, 'module:agenda');                      // prepaid days come back as credit
// ... later ...
$service->addLine($subscription, 'module:agenda', 'Module: Agenda', 7.50);

The history shows two lines; that is the audit trail, not a bug. Pausing a whole subscription does not exist. The nearest supported shapes are ending its cost lines, or cancelling and later subscribing again.

Exporting invoice data

Invoice and InvoiceLine are public models, and the stored rows are the authoritative document: net amounts, VAT split, frozen customer snapshot, period per line. The PDF is only a rendering. Query them directly for exports, accounting sync, or your own rendering:

use PetersDevelopment\LarsaSub\Models\Invoice;

Invoice::query()
    ->whereBetween('invoice_date', [$from, $to])
    ->with('lines')
    ->get();

CreditNote completes the picture for reversals.

Adopting mandates that already exist at Mollie

Record the Mollie customer yourself, then let the package write the mandate: recordExistingMollieMandate() on the owner model finds that customer and writes the row idempotently. The three steps, and why the customer comes first, are in Migrating subscribers in, together with the import flow they belong to. Run larsasub:validate-mandates after a batch.

VAT reporting per country and rate

Everything a per-country, per-rate report needs is frozen on Invoice: customer_country, vat_rate, vat_amount, subtotal, total, reverse_charge, and the per-line split on InvoiceLine for mixed-rate invoices. Group in SQL. Nothing resolves live, so a report over last quarter cannot shift under today's configuration.

CreditNote carries the reversal's own frozen figures (vat_rate, vat_amount, total, reverse_charge) but no customer snapshot: it has no customer_country and no subtotal. A credit note always belongs to one invoice, so join for the country:

use PetersDevelopment\LarsaSub\Models\CreditNote;
use PetersDevelopment\LarsaSub\Models\Invoice;

$invoices = (new Invoice)->getTable();      // carries your larsasub.table_prefix
$notes = (new CreditNote)->getTable();

CreditNote::query()
    ->join($invoices, "{$invoices}.id", '=', "{$notes}.invoice_id")
    ->selectRaw("{$invoices}.customer_country, {$notes}.vat_rate, {$notes}.reverse_charge, SUM({$notes}.vat_amount) AS vat_amount")
    ->groupBy("{$invoices}.customer_country", "{$notes}.vat_rate", "{$notes}.reverse_charge")
    ->get();

This query is right for single-rate notes. A credit note on a mixed-rate invoice has vat_rate = null, so its whole vat_amount lands in a null group. Allocate those across rates yourself, from the invoice's InvoiceLine split and the credited share. See Invoicing and VAT.

reverse_charge is in the grouping on purpose: it is not derivable from the rate. A reverse-charged credit note and an explicitly 0%-rated one both have vat_rate = 0 and can share a country, so grouping on country and rate alone merges two lines a VAT return has to keep apart.

OSS-style quarterly aggregation is deliberately not built in; see Compliance.

Verify a VAT number before the checkout

With VAT-number validation on, a profile save only queues the VIES check, and the reverse charge waits for its answer. A checkout that saves the business profile and starts the first payment in the same request therefore collects VAT on that payment.

To have it reverse charged from the start, check in between. Do it outside your own transactions: it is an HTTP call of up to invoicing.vat_validation.timeout seconds.

use PetersDevelopment\LarsaSub\Helpers\BillableResolver;
use PetersDevelopment\LarsaSub\Service\BillableService;
use PetersDevelopment\LarsaSub\Service\VatNumberValidationService;

$billable = BillableResolver::getOrCreateBillable($owner);                  // the Billable registration

app(BillableService::class)->updateInvoiceDetails($billable, $validated);   // or your own save
$status = app(VatNumberValidationService::class)->checkNow($billable);     // ?VatNumberStatus

// valid: the first payment is reverse charged.
// anything else: VAT now; the queued check and the sweep keep trying, and the
// charges planned after the number is verified are reverse charged.

A quote taken after checkNow() reads the same stored check, and ChargeQuote::$vatNumberStatus tells the checkout which of the two it is showing.

Your own UI on the dashboard data

The bundled dashboards' JSON shapes (Http\Resources\*) are @internal and change without notice. Build your own payload from the public surface instead: the models and their documented methods (paidThrough() on BillableSubscription; payableCharges(), outstandingCharges(), mandateSetupStatus() on the owner through IsBillableModel, …) and the services. Everything the bundled dashboards show is reachable that way; the resources are one arrangement of it.

Mind the N+1 in lists. Each of those methods runs a fresh database query per subscription or per billable, and eager loading does not help: they query rather than read a loaded collection.

BillableSubscription::getNextBillingDate() belongs in the same group and is the easiest one to call by accident, because the bundled BillableSubscriptionResource exposes it as a plain field. It asks the subscription table whether the planner would collect it at all, and the charge table which billing periods are already covered, so it costs one to three queries per row. For a list, query the underlying tables once and group in PHP; keep the per-record methods for detail views and for decisions about one subscriber.