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
- "Paid through" for access control
- Starting the invoice sequence at an existing number
- Sending a payment link outside a browser session
- Pausing a single add-on
- Exporting invoice data
- Adopting mandates that already exist at Mollie
- VAT reporting per country and rate
- Verify a VAT number before the checkout
- Your own UI on the dashboard data
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:
- money under a standing chargeback does not count;
- a chargeback the bank reversed counts as paid again;
- a paid chargeback follow-up covers the period once every follow-up of that original is paid;
- on an imported subscription it is at least the day before
first_charge_on, without any package charge existing.
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.