Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Invoicing and VAT
LarsaSub is payment-first, not an invoicing tool — but every paid charge can produce a numbered invoice, and every refund or chargeback a credit note. This page covers switching that on, how VAT decides what is collected and what is printed, numbering, VAT-number validation, and the built-in PDFs.
Invoicing is off by default, deliberately. Invoices are numbered, legally relevant documents; switching them on is a conscious decision that comes with configuring the VAT rate and the seller details. A fresh install must never silently issue Dutch-VAT invoices with an empty seller. With invoicing off, the package also converts no amount for VAT: every subscription charge collects the listed price.
- Enabling invoicing
- When an invoice is created
- The Invoice model
- Invoice lines
- Numbering
- VAT: what gets collected
- VAT: the frozen snapshot
- Reverse charge
- Validating VAT numbers
- Per-subscription VAT rate
- Credit notes
- Downloading
- Country codes and names
- The built-in PDFs
- Using your own invoicing system
Enabling invoicing
Set LARSASUB_INVOICING_ENABLED=true and fill in the seller. The block in
config/larsasub.php:
'invoicing' => [
'enabled' => env('LARSASUB_INVOICING_ENABLED', false),
'vat_rate' => env('LARSASUB_INVOICE_VAT_RATE', 21.0),
'prices_include_vat' => env('LARSASUB_PRICES_INCLUDE_VAT', true),
'price_display' => env('LARSASUB_INVOICE_PRICE_DISPLAY', 'exclusive'),
'country_display' => env('LARSASUB_INVOICE_COUNTRY_DISPLAY', 'name'),
'seller' => [
'name' => env('LARSASUB_INVOICE_SELLER_NAME'), // falls back to config('app.name')
'vat_number' => env('LARSASUB_INVOICE_SELLER_VAT'),
'coc_number' => env('LARSASUB_INVOICE_SELLER_COC'),
'address' => env('LARSASUB_INVOICE_SELLER_ADDRESS'),
'country' => env('LARSASUB_INVOICE_SELLER_COUNTRY', 'NL'),
],
'number_prefix' => env('LARSASUB_INVOICE_PREFIX', 'INV'),
'credit_note_prefix' => env('LARSASUB_CREDIT_NOTE_PREFIX', 'CN'),
'vat_validation' => [ /* see Validating VAT numbers */ ],
],
Boot refuses a price_display other than exclusive/inclusive and a
country_display other than name/code, and warns when invoicing is on
without a seller VAT number.
In Connect mode the seller can differ per tenant: implement
getInvoiceSellerDetails() on your tenant model, returning name,
vat_number, coc_number, address and country, and it takes precedence
over the config block. Its country is also the seller country the VAT rules
run against.
The built-in PDFs need barryvdh/laravel-dompdf. It is a suggested dependency,
not a required one — see Using your own invoicing system.
When an invoice is created
| Trigger | Path |
|---|---|
A RecurringCharge is paid |
Automatic, from the paid webhook (or a local settlement, such as a charge covered by credit) |
A SinglePayment invoice is requested |
On demand only: the payments dashboard's invoice button, or InvoiceService::createForSinglePayment() |
| Backfilling existing data | php artisan larsasub:generate-missing-invoices |
use PetersDevelopment\LarsaSub\Service\InvoiceService;
$service = app(InvoiceService::class);
$invoice = $service->createForCharge($charge); // ?Invoice
$invoice = $service->createForSinglePayment($payment); // Invoice
Both are idempotent — a payment carries at most one invoice
(invoiceable_type + invoiceable_id is unique), and a second call returns
the existing one. Neither checks invoicing.enabled or whether the payment is
paid: the automatic path, the command and the download routes do. Call them
yourself only for a paid payment.
When the automatic invoice fails, the payment still stands: the error is
logged and the charge is flagged needs_attention, which reaches your
operators through the company mail. Run larsasub:generate-missing-invoices
once the cause is fixed.
larsasub:generate-missing-invoices invoices every paid RecurringCharge
without an invoice (mandate-setup charges and superseded charges excluded;
single payments are not covered). It takes --limit= and --dry-run, does
nothing while invoicing is off, and invoices a charge in full even when part or
all of it was refunded or charged back — check those afterwards and see
Refunds → Credit notes.
One category is never invoiced: mandate-setup charges. The €0.01 verification payment is not a delivery, so
createForCharge()returnsnullfor it. That single choke point is deliberate — all three generation paths (the paid webhook,larsasub:generate-missing-invoices, the on-demand download) run through it, so no path can mint an invoice for it and consume a number.
createForCharge() also returns null for an unpaid chargeback follow-up
whose chargeback was reversed: nobody owes it.
The Invoice model
Models\Invoice (larsasub_invoices) is a finalized snapshot. It records the
seller, the customer and the amounts as they were at the moment it was
created. The model enforces that: an update throws a LarsaSubException,
except the move from finalized to voided and the external download URL, and
an invoice cannot be deleted — correct one with a credit note.
| Property | Description |
|---|---|
invoice_number |
Unique, sequential — see Numbering |
status |
finalized or voided |
billable_id |
The Billable registration |
invoiceable_type / invoiceable_id |
The charge or single payment (unique) |
tenant_type / tenant_id |
The tenant, in Connect mode |
customer_name, customer_email, customer_company_name, customer_vat_number, customer_coc_number, customer_address, customer_country |
Customer snapshot |
customer_vat_check_reference |
The VIES consultation number the reverse charge rests on — only on a reverse-charged invoice with the check on, and only when VIES issued one; kept after anonymize(), not printed on the PDF. See Validating VAT numbers |
seller_name, seller_vat_number, seller_coc_number, seller_address, seller_country |
Seller snapshot |
subtotal, vat_amount, total, currency |
Amounts — total is the full invoiced value, in the currency the payment was collected in |
vat_rate |
Invoice-level rate; null when the lines carry a mixed split |
reverse_charge |
Whether the EU reverse charge was applied |
credit_applied |
Credit consumed; the customer was debited total - credit_applied |
description, payment_method_label |
|
invoice_date |
The day the invoice was created |
external_download_url |
Set when an external InvoiceRenderer owns the document |
$invoice->lines; // HasMany InvoiceLine, ordered by sort_order
$invoice->hasLines();
$invoice->billable;
$invoice->invoiceable; // the charge or single payment
$invoice->tenant;
$invoice->creditNote; // the most recent credit note
$invoice->creditNotes; // all of them
$invoice->isVoided();
$invoice->hasExternalDownload();
$charge->invoice; // MorphOne, from the payment side
Creating an invoice dispatches InvoiceGeneratedEvent; a credit note
dispatches CreditNoteGeneratedEvent (Events). To
mail the PDF with the subscriber mails, see
notifications.attachments.
Invoice lines
Models\InvoiceLine is created together with the invoice and is immutable in
the same way.
| Property | Description |
|---|---|
invoice_id |
Owning invoice |
key |
Origin cost-line key (module:agenda); null for base and single lines |
description |
|
quantity |
1 for subscription lines; order lines carry their real quantity |
unit_amount, amount |
Net of VAT — invoice subtotal semantics |
vat_rate, vat_amount |
Per-line VAT, set for single-payment order lines; null = the invoice-level VAT is authoritative |
period_start, period_end |
Billed period |
sort_order |
Display order |
Where the lines come from:
createForCharge()writes one line peramount_breakdownentry — the base price plus each cost line, with billed periods. A charge without a breakdown (no cost lines, a standalone charge) gets a single line, with the charge's service period when it belongs to a subscription.createForSinglePayment()writes one line perSinglePaymentLinewhen the payment has order lines, otherwise one line with the payment description.- Invoices created before invoice lines existed have none; the PDF falls back to a single description row.
Amount semantics. Line amounts follow the invoice's subtotal (net of VAT):
- Whenever the charged amounts include VAT — always with
invoicing.prices_include_vat = true, and with exclusive prices for every charge whosevat_treatmentisincludedoradded— they are converted to net per line, and thesubtotalis the sum of those amounts. - Lines always sum exactly to the invoice subtotal. On a multi-line charge whose entries sum to the collected amount — the normal shape — no line is reshaped to make that true.
- Two shapes keep a last-line rounding absorber: a single line that disagrees
with the collected amount, and a stored breakdown whose rows no longer sum to
it. In both, the subtotal comes from the collected amount, so the
disagreement lands visibly on a line rather than inside
vat_amount. vat_amountis thentotal − subtotal, andtotalis literally what was collected (before credit) — neversubtotalplus a separately rounded VAT, which on small amounts is a different number.
This is the same per-row arithmetic the charge itself is composed with and the
same split quoteFirstCharge()
publishes before the customer pays, so a checkout screen and the invoice for
one sale cannot name different numbers.
A charge with a single line — a first payment without cost lines, a legacy row, a single-amount payment — de-grosses the collected amount: there is no composition to follow, and the money that moved is the authority.
Numbering
Models\InvoiceSequence issues one continuous sequence per document type and
per tenant: INV-000001, INV-000002, … and CN-000001 for credit notes. The
prefix comes from invoicing.number_prefix (invoices) and
invoicing.credit_note_prefix (credit notes). The number is zero-padded to six
digits and grows past them on its own.
In Connect mode a tenant's documents carry the four-character reference code of
its connected Mollie account in front (LCRA-INV-000001), and the tenant has
its own sequence. The code stays after a disconnect, so a credit note for an
earlier payment still carries it.
The sequence never resets — there is no year in the number. A per-year
number like INV-2026-00842 tells every recipient how many invoices you sent
this year, and with monthly subscriptions that is close to your customer
count. The invoice date carries the year, which is all the law asks
(art. 226 of Directive 2006/112/EC: a sequential number in one or more series
that identifies the invoice uniquely).
For your bookkeeping this means a gap check per financial year runs on
invoice_date, not on the number. Both are taken when the document is created,
so a document that larsasub:generate-missing-invoices creates in January for
a payment of December is a January document either way.
A fresh installation starts at INV-000001, which still says "first invoice".
If that matters to you, seed the sequence once before the first invoice — see
Starting the invoice sequence at an existing number.
A number is only consumed when a document is actually created, and a document
cannot be deleted afterwards — which is why mandate-setup charges returning
null matters: skipping them leaves no gap in the sequence.
VAT: what gets collected
invoicing.prices_include_vat is the single most consequential setting here,
because it changes what is collected, not just what is printed. Both
conversions below run only with invoicing.enabled on.
| Setting | price_amount means |
What gets debited |
|---|---|---|
true (default, B2C) |
Gross — VAT is already inside it | The listed amount. A zero-rated customer (EU reverse charge, outside the EU) pays the listed amount minus the VAT |
false (B2B) |
Net — VAT is added before collection | A €14,90 plan debits €18,03 domestically |
Either way the invoice totals exactly what was settled: invoice.total equals
the charge's pre-credit amount, and total - credit_applied equals what was
actually debited. With exclusive prices the invoice splits that €18,03 back into
€14,90 + €3,13. The conversion runs per breakdown row and the rows are summed,
so the charge total is exactly the sum of its invoice lines.
Three cases are never grossed up:
- Zero-rated customers — EU B2B reverse charge, and customers outside the EU.
- Customers without an
invoice_country. The package does not add VAT on a guess any more than it strips it on one; the invoice then treats the collected amount as VAT-inclusive. - Single payments. A
SinglePayment'samountis contractually what the provider collects, so with exclusive prices only its invoice changes, not the debit; for a zero-rated customer nothing is stripped either. A charge fromcreateStandaloneCharge()follows the same rule.
Because the debit differs from the listed price in both directions, the subscriber mails that quote an amount — activation, started, renewal announcement, renewed, plan changed, "your subscription amount has changed" and "the VAT rate changed" — label their total row accordingly ("incl. 21% VAT", "VAT reverse-charged" or "0% VAT") and show it even when there are no cost lines. See Notifications.
VAT: the frozen snapshot
VAT is decided when the money moves, not when the invoice is written.
Every charge and single payment the package creates stores the rate it was
actually collected at (vat_rate, 0.00 = zero-rated) and how the VAT relates
to the collected amount (vat_treatment, an Enums\VatTreatment) — also while
invoicing is off:
VatTreatment |
Meaning |
|---|---|
Excluded |
No VAT is in the collected amount |
Included |
The listed price already carried it — or the country was unknown and the package refused to gross up on a guess, or invoicing was off when the charge was created |
Added |
The package grossed the amount up before collecting |
Stripped |
No VAT is in the collected amount: the customer is zero-rated (reverse charge, outside the EU) on VAT-inclusive prices, and the package removed the VAT from the listed price before collecting |
The invoice splits out Included and Added; Excluded and Stripped carry
no VAT. A cost-line refund is valued from listed prices, so it re-adds the VAT
only for Added and removes it only for Stripped — a later
prices_include_vat switch cannot move the credit for days billed under the
old mode. Test for "no VAT in this amount" with ! $treatment->includesVat(),
never with === VatTreatment::Excluded: a zero-rated charge on inclusive
prices is Stripped. Single payments and standalone charges are never
Added or Stripped.
The charge also freezes the profile fields that decision rested on — country,
type and VAT number, and with VAT-number validation
on the check of that number (vat_check: status, consultation number, date) —
in vat_customer, so the invoice states the customer the rate applied to. A
reverse-charge line next to a profile that has since dropped its VAT number
would be unjustifiable. Name and address still come from the current record.
The invoice follows that snapshot instead of re-resolving the plan rate and the
country rules, because those inputs move: a plan rate can be edited, and
invoice_country / invoice_type / invoice_vat_number are fields the customer
changes themselves. An invoice generated days or months later therefore reports
the VAT that was actually collected — and a charge created while invoicing was
off is invoiced for what it collected when you switch invoicing on later.
Two kinds of row carry no snapshot: the €0.01 mandate-setup charge (never invoiced at all) and rows from before 0.14. Those resolve the rate and the country rules live at invoicing time.
vat_customeris encrypted. It isencrypted:arrayon alongTextcolumn (a ciphertext is not valid JSON, so ajsoncolumn would be rejected by MySQL).Billable::anonymize()clears it,withTrashed()included — it is the only VAT column erasure touches. Rate, treatment and the reverse-charge flag are facts about the transaction, andvat_treatmentis what a refund reads to decide whether it may re-add VAT, or has to remove it.
Reverse charge
The EU reverse charge applies to a customer in another member state than the seller (both in the EU) only when:
- the billable's
invoice_typeisBillable::INVOICE_TYPE_BUSINESS, and - a VAT number is stored, and
- with VAT-number validation on: VIES confirmed
that number (
vat_check_status = valid). Off, the stored number is enough.
The seller country is invoicing.seller.country, or under Connect the tenant's
getInvoiceSellerDetails()['country']. A domestic customer always pays VAT; a
customer outside the EU pays none, without a reverse charge.
The profile type and the VAT number cannot disagree: Billable clears
Billable::BUSINESS_ONLY_FIELDS (company name, VAT number, CoC number) on
every model save where invoice_type is not business. A personal profile can
therefore never carry a number into a reverse charge, which would produce an
invoice with reverse_charge = true and no customer VAT number. A
query-builder mass update skips that hook. See
Billable models.
Whether a supplied VAT number is genuine is what Validating VAT numbers answers — opt-in; off, it stays yours. See Compliance.
Validating VAT numbers
Opt-in: invoicing.vat_validation.enabled (LARSASUB_VAT_VALIDATION_ENABLED),
off by default. Off, the package makes no outgoing call for it. On, every
business profile with a VAT number in an EU country is checked in
VIES, the European Commission's
register, and only a confirmed number is reverse charged.
When a number is checked
- After every change of
invoice_type,invoice_countryorinvoice_vat_number: the save marks the numberpendingand queues a job (Jobs\CheckVatNumber) that runs once the save has committed, never inside it. No answer is retried with backoff (1 min, 5 min, 30 min, 2 h). The same number in another spelling (DE 123 456 789forDE123456789,deforDE) keeps avalidcheck; any other status is checked again. - Twice a day, by
larsasub:check-vat-numbers: numbers never checked, numberspendingfor more than 15 minutes (the queued job never ran),unavailableones, andvalidones older thaninvoicing.vat_validation.recheck_after_days(default 30;null= only on change). VIES only confirms a number for today. The command calls VIES one number after another, never in parallel.
Domestic numbers are checked too: deciding "cross-border" needs the seller,
which under Connect is the tenant. GB and Northern Ireland (XI) are not
checked — outside the EU there is no reverse charge to justify.
The outcome
The outcome lives on the registration:
| Column | Meaning |
|---|---|
vat_check_status |
An Enums\VatNumberStatus, below; null = no check applies, or never checked |
vat_check_number |
The normalized number the status belongs to: the VIES country code plus the number without spaces, dots or its own prefix (NL123456789B01; Greece is EL123456789) |
vat_check_reference |
The consultation number (VIES requestIdentifier) of the last valid answer — only issued when the check carried a requester |
vat_checked_at |
When VIES last gave a definitive yes or no |
VatNumberStatus |
Meaning |
|---|---|
pending |
The number changed while the check is on; the queued check has not answered yet |
valid |
VIES confirmed the number on vat_checked_at. A later check without an answer keeps it; only a definitive no or a change of the number replaces it |
invalid |
VIES said no — the number does not exist, or VIES refused it as input |
unavailable |
Never found valid, and the last attempt got no answer (a member state in maintenance, a VIES limit, a timeout, your own requester refused). Retried by the job, then twice a day |
A status only counts for the number it was decided for. A query-builder mass
update of invoice_vat_number skips the model hook that resets the check; the
stale status then belongs to vat_check_number, not to the new number, and
VatNumberValidationService::statusFor() answers null for it.
The requester is the seller's own VAT number, so the consultation is issued
to whoever sells: invoicing.seller.vat_number in simple mode, the tenant's
getInvoiceSellerDetails()['vat_number'] under Connect — never the platform's
number for a tenant's sale. Without one VIES still answers, without a
consultation number; boot and larsasub:doctor say so. VIES refusing your own
requester (INVALID_REQUESTER_INFO) counts as no answer and is logged as an
error.
What the check decides
With the check on, the reverse charge applies only to a
number VIES confirmed. Until then — pending, invalid, unavailable or
never checked — the business customer is charged VAT and reclaims it from their
own tax office, and you carry no risk for a number nobody verified. Domestic
customers and customers outside the EU are not affected: they pay VAT, or none,
whatever VIES says.
- Frozen per charge. The VAT decision is snapshotted when a charge or
single payment is created (the frozen snapshot).
A charge planned while the number was
pendingkeeps its VAT whenvalidarrives before it is collected; one planned while it wasvalidkeeps its reverse charge when a later re-check saysinvalid— on the day of the sale the number was valid. Nothing already planned is revalued. - The proof travels with the sale. The snapshot carries the check
(
vat_customer['vat_check']: status, consultation number, date), and a reverse-charged invoice copies the consultation number intocustomer_vat_check_reference.anonymize()clears the registration's check and the snapshot; the invoice keeps its copy, like the VAT number beside it. With invoicing off, keep it yourself fromVatNumberCheckedEventor the charge'svat_customer. - The quote says so.
quoteFirstCharge()prices the same way and reportsChargeQuote::$vatNumberStatuswhen the reverse charge hinges on the check (cross-border EU B2B with the check on):validis the reverse-charged quote, anything else carries VAT — "with VAT now, reverse charge once the number is verified". A suppliedVatProfilecarries no check and is quoted with VAT, unless it restates the stored, verified number. - Saving and collecting at once. A profile save only queues the check, so
a host that saves the profile and starts the payment right away collects VAT
on that first payment. Call
checkNow()between the two when the reverse charge should apply from the first payment (recipe).
Nothing is mailed to the customer about a refused number and the save is never
blocked — listen to VatNumberCheckedEvent (Events)
if you want either. VIES's name and address are not read or stored, and nothing
is compared with them: that part of the verification stays yours
(Compliance). The profile dashboard shows one line under the
VAT number with the status (and the day it was verified).
Switching it on with existing customers
Do it in this order:
- Check the numbers you already have:
php artisan larsasub:check-vat-numbers --uncheckedruns while the feature is still off (and with the scheduler off). - Then switch it on.
A number not checked by then loses its reverse charge until it is: the next
charge of an existing reverse-charged customer carries VAT — on inclusive
prices nothing is stripped any more, on exclusive prices the VAT is added.
larsasub:doctor counts what is still unchecked.
In your own code
use PetersDevelopment\LarsaSub\Service\VatNumberValidationService;
$service = app(VatNumberValidationService::class);
$service->statusFor($billable); // ?VatNumberStatus for the CURRENT number
$service->checkNow($billable); // ask VIES now (up to `timeout` seconds) and return that status
$service->check($billable); // ask VIES now; returns the raw VatNumberCheckResult, or null when no check applies
$service->checkDue(); // the scheduled sweep, when you run the scheduler yourself
checkNow() and check() do not look at invoicing.vat_validation.enabled —
calling them is an explicit act — and do not set a tenant context: in your own
request that is yours. Call them outside your own database transactions.
checkDue(?int $limit = null, bool $uncheckedOnly = false) returns a report
with the keys taken, valid, invalid, unavailable, skipped and
errors.
To replace VIES — a cache in front of it, another source — implement
Contracts\VatNumberValidator and name the class in
invoicing.vat_validation.validator:
use PetersDevelopment\LarsaSub\Contracts\VatNumberValidator;
use PetersDevelopment\LarsaSub\Support\VatNumberCheck;
use PetersDevelopment\LarsaSub\Support\VatNumberCheckResult;
class CachedVatNumberValidator implements VatNumberValidator
{
public function check(VatNumberCheck $check): VatNumberCheckResult
{
// $check->country is the VIES code (EL for Greece), $check->number the
// national part; $check->requesterCountry / ->requesterNumber are the
// seller, or null without one.
return VatNumberCheckResult::unavailable(); // or ::valid(...) / ::invalid(...)
}
}
"No answer" must be VatNumberCheckResult::unavailable(); anything thrown
counts as that.
Configuration
| Key | Default | Meaning |
|---|---|---|
invoicing.vat_validation.enabled |
false |
The switch (LARSASUB_VAT_VALIDATION_ENABLED) |
invoicing.vat_validation.validator |
null |
A Contracts\VatNumberValidator class; null = the bundled VIES client |
invoicing.vat_validation.recheck_after_days |
30 |
Days after which a valid number is checked again; null = only on change (LARSASUB_VAT_RECHECK_DAYS) |
invoicing.vat_validation.timeout |
10 |
Seconds one VIES call may take |
scheduling.check_vat_numbers_frequency |
twiceDaily |
Schedule of larsasub:check-vat-numbers |
scheduling.check_vat_numbers_delay_ms |
500 |
Pause between two VIES calls in the sweep |
scheduling.check_vat_numbers_limit |
null |
How many registrations one run takes up; empty = all (also --limit=) |
Boot refuses a validator class that does not implement the contract and a
recheck_after_days that is neither null nor a positive whole number. It
warns when the check is on without invoicing (the check then changes labels,
not amounts), or without a seller VAT number in simple mode.
Per-subscription VAT rate
Subscription and BillableSubscription both carry a nullable vat_rate. The
rate a subscription charge is grossed up or stripped with, and the one its
snapshot freezes, is resolved when the charge is composed, on its
collection day:
BillableSubscription.vat_rate (an override) → Subscription.vat_rate (now) → invoicing.vat_rate (now)
The rate is not copied onto a subscription when it is created. A period is paid in advance, and VAT on a payment on account is due at the rate in force on receipt (Directive 2006/112/EC, art. 65 and 93), so a legal rate change, or a corrected classification on the plan, reaches every running subscriber from their next charge. The price stays frozen: that is a contract term. A charge that exists keeps the rate it was composed at, and an invoice is built from the charge's snapshot — paid charges never move.
BillableSubscription.vat_rateis an explicit override for that one subscription (a customer whose service is exempt, say). Leave it empty to follow the plan.- A plan that is soft-deleted keeps its rate for the subscribers it still bills.
- A rate of
0is an explicit 0% (VAT-exempt), distinct fromnull(= fall through). Consumers that configure nothing keep the config rate — VAT is never required.
$billableSubscription->getEffectiveVatRate(); // null = the config rate applies
When a plan's own rate changes through the model, SubscriptionVatRateChangedEvent
fires and the subscribers without a rate of their own whose debit changes —
on exclusive prices a customer who pays VAT, and a zero-rated customer on
inclusive prices — get a mail
with the new amount (notifications.user.subscription_vat_rate_changed, on by
default). There is no lead time: the next charge uses the new rate. A change of
invoicing.vat_rate in the configuration is not observable and sends nothing;
see Compliance.
Credit notes
A refund or chargeback voids invoiced value through a credit note, and for
everything the provider reports the package writes it for you: each
completed refund, each chargeback, and credit given back from a paid charge
with CreditService::returnAppliedCredit() produces a note for exactly that
amount, settled against one cumulative per invoice. A refund you request with
RefundService::refund() is covered the same way once it completes — see
Refunds. The invoice becomes voided when the notes
reach its total; a partial note leaves it finalized.
Call createCreditNote() yourself only for a correction outside a provider
refund:
$creditNote = app(InvoiceService::class)->createCreditNote($invoice, 'Correction #1234', 12.50);
createCreditNote(Invoice $invoice, string $reason, ?float $creditedCumulative = null)
returns null when there is nothing left to credit. $creditedCumulative is
the cumulative credited amount after this note, measured against every
note on the invoice — the automatic ones included — so a series of partial
credits cannot over-credit it. Leaving it null credits the whole remaining
invoice.
Mixing the two is where money disappears from the books, in both directions:
- The automatic notes compute their target from refunds, chargebacks and returned credit only. They do not know about your manual note. Credit €12,50 by hand, and a later €10 refund computes a target of 10, finds 12,50 already credited, and writes no note — money went back with no document.
- Your manual cumulative must include the automatic notes already on the invoice. Pass 12,50 on an invoice that already has a €10 refund note, and you credit only €2,50.
So: correct an invoice that may still see a provider refund by refunding through the provider, not with a manual note.
Models\CreditNote (larsasub_credit_notes) is numbered from its own sequence
and is immutable like the invoice. It references the invoice it credits
(invoice_id, $creditNote->invoice) and stores its own total,
vat_amount, vat_rate, reverse_charge, currency, reason and
issued_at; seller and customer details are the invoice's. Its VAT is the
invoice's VAT in proportion to the cumulative credited amount, so the notes'
VAT adds up to the invoice's VAT exactly once the invoice is fully credited.
Downloading
The bundled dashboards expose endpoints that stream the PDF. The two payments endpoints generate the invoice first when it does not exist yet (idempotently):
| Route name | URL |
|---|---|
larsasub.my-payments.charge-invoice |
/billing/payments/charges/{charge}/invoice |
larsasub.my-payments.single-payment-invoice |
/billing/payments/single-payments/{singlePayment}/invoice |
larsasub.my-invoices.download |
/billing/invoices/invoices/{invoice}/download |
larsasub.my-invoices.download-credit-note |
/billing/invoices/credit-notes/{creditNote}/download |
larsasub.tenant-invoices.download |
/billing/admin/invoices/invoices/{invoice}/download |
larsasub.tenant-invoices.download-credit-note |
/billing/admin/invoices/credit-notes/{creditNote}/download |
Guards: the ownership policy, and invoicing must be enabled (404); the two
payments endpoints also require a paid payment (422). The invoices dashboards
— and so their routes — are only registered with invoicing on. The tenant routes serve only documents of the
current tenant (simple mode: the operator sees all). The charge and
single-payment API resources expose invoice_url (null when not applicable),
and the payments dashboard renders an "Invoice (PDF)" button for paid rows.
Programmatically:
$service->downloadInvoice($invoice); // StreamedResponse, or a redirect to an external URL
$service->downloadCreditNote($creditNote);
Country codes and names
Helpers\CountryHelpers owns the country data:
use PetersDevelopment\LarsaSub\Helpers\CountryHelpers;
CountryHelpers::normalize(' nl '); // 'NL' ('' becomes null)
CountryHelpers::isEu('NL'); // true — the 27 member states
CountryHelpers::name('NL'); // 'Netherlands' — 'Nederland' under nl
CountryHelpers::nameOrCode('XX'); // 'XX'
CountryHelpers::display('NL'); // per invoicing.country_display
CountryHelpers::options(); // for a form select
CountryHelpers::euOptions();
CountryHelpers::isKnown('NL');
The code is the source of truth. Every VAT comparison runs on normalized codes, never on names. The bundled names come from a committed ISO 3166-1 list generated from ICU, so a code yields the same name across servers and ICU versions.
The list carries current codes only — deprecated aliases (DD, FX, SU) are
excluded on purpose: they are selectable duplicates that isEu() does not know,
so picking one would zero-rate an EU customer.
invoicing.country_display decides how invoices and credit notes print a
country: 'name' (default, Netherlands) or 'code' (NL). Unknown codes
always fall back to the code — an invoice must never lose information because a
name is missing.
Names ship in en and nl: English from the bundled list, Dutch as
larsasub::countries.{CODE} translation keys covering the same 252 codes. The
name follows the locale the document renders in. For another language, or
different wording, define larsasub::countries.{CODE} in your own app; that
overrides the package's name.
The built-in PDFs
The package renders invoices and credit notes with dompdf, from two Blade views. This section covers their language, how amounts are laid out, and how to change the views.
The document's language
The document's language is the customer's. The built-in invoice and
credit-note PDFs render in the billable's content locale — the language their
frozen lines are written in — whoever opens them, so the attachment in the
customer's mail and a download by an operator are the same document. Amounts
use the separators of the package's two UI languages — 1.234,56 for nl,
1,234.56 for en and every other locale — beside the currency code, and
dates are written out (5 October 2026 / 5 oktober 2026).
That locale is larsasub_billables.locale when one is stored (your owner
model's billable_locale), else locales.default, else the first of
locales.supported. On a single-language install with none of those set it
falls back to app.locale — and Laravel changes that value whenever a request
switches locale, so the document then follows whoever opens it. Set
larsasub.locales.default (or store a locale per billable) to pin it.
Price display
invoicing.price_display is presentation only; stored invoice data is always
net + VAT.
| Value | Layout |
|---|---|
'exclusive' (default) |
Line amounts ex VAT; subtotal + VAT + total (B2B style) |
'inclusive' |
Line amounts incl VAT; total first, then "of which VAT" per rate group, with its taxable base (B2C style) |
Mixed-rate invoices get one "of which VAT" row per rate; single-rate invoices show the stored invoice-level amounts, with no rounding re-derivation. In the inclusive layout the per-line gross amounts are made to add up to the stored total: a rounding remainder of at most a cent per line lands on the last line.
When credit was applied, both layouts print it below the total, followed by the
amount actually due (total - credit_applied). A reverse-charged invoice
carries the reverse-charge statement.
Customizing the PDF views
Publish the two views alone:
php artisan vendor:publish --tag=larsasub-invoice-views
They land in resources/views/vendor/LarsaSub/pdf (invoice.blade.php,
credit-note.blade.php). A published view is frozen at the version you
published: re-publish with --force after an upgrade that changes them, and
re-apply your changes.
Keep the views safe for dompdf: print data escaped ({{ }}, or
{!! nl2br(e(...)) !!} for line breaks) and load no remote assets — embed
images and fonts locally.
Using your own invoicing system
Bind your own Contracts\InvoiceRenderer. LarsaSub still creates the invoice
and credit-note records (numbers, lines, VAT split); your renderer decides where
the document is downloaded:
namespace App\Billing;
use PetersDevelopment\LarsaSub\Contracts\InvoiceRenderer;
use PetersDevelopment\LarsaSub\Models\CreditNote;
use PetersDevelopment\LarsaSub\Models\Invoice;
class ExternalInvoiceRenderer implements InvoiceRenderer
{
public function getDownloadUrl(Invoice $invoice): ?string
{
// Push $invoice (and $invoice->lines) to your own system and return
// its download URL — or null to use LarsaSub's own PDF.
return app(MyAccountingClient::class)->publishInvoice($invoice);
}
public function getCreditNoteDownloadUrl(CreditNote $creditNote): ?string
{
return app(MyAccountingClient::class)->publishCreditNote($creditNote);
}
}
// A service provider
$this->app->bind(InvoiceRenderer::class, ExternalInvoiceRenderer::class);
MyAccountingClient stands for your own integration. The renderer is called
once per document, right after the record is committed, and the URL it returns
is stored in external_download_url; the download endpoints and
downloadInvoice() / downloadCreditNote() then redirect there. Returning
null falls back to LarsaSub's own PDF. A renderer that throws is logged and
the document falls back to the package PDF — invoice generation never fails on
it.
With your own renderer bound and always returning a URL, dompdf is not needed at
all; without dompdf, the built-in renderer throws a LarsaSubException naming
the missing package rather than a class-not-found mid-stream.