Taxes
Tax is applied through the billing.tax mode:
provider— provider tax (Stripe Tax) is collected on the hosted checkout and flows onto the persisted invoice.eu_oss— a local EU One-Stop-Shop rate table computes the tax.none— no tax is applied.
The computation path is a seam: the package binds a TaxCalculator (the EU-OSS table, or none) so a local
computation can replace the provider path without touching the invoicing code.
Reverse charge, only on a validated VAT id
Intra-EU B2B reverse charge is rendered as EN 16931 category AE. The reverse-charge zero rate is applied
only when both are true:
- the buyer's VAT id is validated — a
VatIdValidatorseam, with a VIES-backed implementation shipped and a null default that proves nothing offline; and - the supply is cross-border — the buyer's country differs from
billing.company.country.
So a fake id, a VIES outage, or a domestic sale is never wrongly zero-rated. A correction inherits the tax
treatment of the invoice it corrects, so a reverse-charge invoice's correction is itself category AE
(never the zero-rated Z a 0% rate would default to).
The recapitulative statement
A reverse-charged sale to a business in another member state also belongs in a recapitulative statement: per VAT id, what that business bought in the period. If you book through DATEV, the booking carries the VAT id and DATEV reads the statement from it (Accounting and DATEV). Without DATEV, export it:
php artisan billing:recapitulative-statement:export # the quarter that has just ended
php artisan billing:recapitulative-statement:export --year=2026 --quarter=1 --path=storage/app/zm.csv
php artisan billing:recapitulative-statement:export --year=2026 --month=2 # one month, where the statement is monthly
One row per VAT id and kind of supply, amounts in the currency's decimal form:
period;country;vat_id;kind;net
2026-Q1;AT;ATU12345678;goods;-12.50
2026-Q1;FR;FR12345678901;services;150.00
- A correction counts in the quarter it is issued in, with its sign, and can take a buyer's line below zero. A change to the taxable amount is reported for the period in which it is made and never written back into a statement already submitted.
- Goods and services are separate lines, told apart the way the document's e-invoice tells them apart.
- A reverse-charged sale that names no VAT id stops the export, and the error names the sale. The statement is a list of VAT ids, and leaving the sale out would make it short with nothing looking wrong.
- A sale is in this statement or in the one-stop-shop return, never in both. The placement that sends a sale to the consumer scheme never charges it in reverse.
Every run is recorded beside the one-stop-shop return's runs, in billing_tax_return_exports with
return_type set to recapitulative_statement, and a copy goes where billing.tax_oss.export_disk and
billing.tax_oss.export_path point. Under the German profile the command prints the deadline: the 25th day
after the quarter (§ 18a Abs. 2 UStG), or after the month for a monthly statement (§ 18a Abs. 1 UStG).
A monthly statement
Supplies of goods to businesses in other member states are reported for each month once they pass a limit in
a quarter or in one of the four before it. The German profile sets the limit at 50,000 euros (§ 18a Abs. 1
Satz 2 UStG). Past it, the quarterly export refuses and names the quarter that went over, because a quarterly
file is then the wrong document to submit; export each month with --year and --month instead. A month's
file carries the month as its period (2026-02) and is recorded under the quarter it falls in.
- The limit counts goods only. Services to businesses abroad stay quarterly under § 18a Abs. 2 UStG, however much they come to.
- A sale without its VAT id still counts toward the limit. The limit is about what was supplied, so a missing id cannot keep you under it; the export itself still stops at that sale.
- The limit is in euros, and it is measured on your euro documents. A statement in another currency is not held to it.
- The month in which the limit is passed starts the monthly statements. § 18a Abs. 1 UStG then asks for that month together with the earlier months of the same quarter; export each month and submit them as the statement for those months.
Under provider that check is Stripe's, and it is not the same check. Stripe Checkout asks for a tax ID and
applies the reverse charge as soon as the ID has the right format; whether the ID is valid is verified only after
the session. A consumer who types a well-formed but invalid ID therefore buys without VAT, and the tax is still
owed. A platform that sells to consumers can leave the field out with billing.checkout.tax_id_collection set to
false: the checkout then charges every buyer the tax of their country, while Stripe still computes it.
A platform that sells to consumers and to businesses needs both answers. The setting is only the default, and a
single checkout decides for itself: subscribe($owner, 'pro', collectTaxId: true) or purchase($owner, 'extra', collectTaxId: true) asks a business for its tax ID while every consumer checkout stays without the field, and
collectTaxId: false does the opposite on an installation that asks by default.
A business whose VAT number you already hold, from its sign-up or your own form, does not need the field at all.
Attach the number to its customer before the checkout; the package checks it with the bound VatIdValidator and
attaches it only when it is proven valid. A Stripe Checkout for a customer who carries a tax id shows no field to
enter one, and Stripe Tax applies the reverse charge to a cross-border sale on the number it finds:
use Pushery\Billing\Contracts\AttachesVerifiedVatIds;
use Pushery\Billing\Contracts\OneTimeCharge;
use Pushery\Billing\Enums\VatIdValidation;
if (app()->bound(AttachesVerifiedVatIds::class)) {
$outcome = app(AttachesVerifiedVatIds::class)->attach($advertiser, $advertiser->vat_id);
// VatIdValidation::Valid: attached. Invalid or Unavailable: nothing attached, and the buyer pays their country's tax.
}
app(OneTimeCharge::class)->purchase($advertiser, 'advertising_credit', collectTaxId: false);
The same holds for subscribe(). Bind ViesVatIdValidator for the check to reach the register; the default proves
nothing, so it attaches nothing. The number is checked when it is attached, and nothing checks it again afterwards:
call detach() for a business whose number no longer holds.
Where the package bills a cycle itself, the reverse charge rests on the register's answer instead: a cycle is
reverse-charged only while the latest answer on one of the buyer's tax IDs is verified. A later unverified
answer, or the buyer removing the ID, ends it for every cycle after; a pending or unavailable answer changes
nothing. Stripe reports the removal with customer.tax_id.deleted, so the endpoint needs it beside
customer.tax_id.created and customer.tax_id.updated.
The rate band follows from what was sold
The band a sale is taxed at comes from the product's classification, exactly as its place does: an ebook is
taxed at the reduced band because the taxonomy says so, whatever band the buyer's TaxContext carries, and a
tip takes the band of what it was paid on. Two things stay with the caller. Whether the item carries sound or
video (hasAudioVisualComponent) closes the reduced band in several member states, and only the caller
knows it. And the rates themselves: without billing.tax_matrix, every band is priced at the standard rate.
A sale made in person is taxed where it is made
SaleTaxDecision::decide() and decideOnGross() take the country a sale is made in as soldAt. Goods handed
over at a point of sale are supplied there (§ 3 (6) and (7) UStG, Articles 31 and 32 of the VAT Directive),
whoever buys them. So for a product whose taxonomy names an in-person place, the sale is placed at the point
of sale (PlaceOfSupplyRule::PointOfSale):
- a business from another member state does not move the place with its VAT id, so the sale is not reverse-charged;
- a tourist pays the tax of the country the shop is in, and the sale stays out of the One-Stop-Shop scheme and out of the distance-sale threshold, because it records no destination country;
- a shop in another country than the seller's own is taxed at that country's rate, which assumes the seller is registered there.
A service keeps its place when it is paid for in person: a one-to-one session paid at the counter is still
placed where the business that receives it is established. Without soldAt nothing changes. A soldAt
that is not a two-letter country code, and a point-of-sale supply with no country at all, are refused with
PointOfSaleUnknown.
One limit is deliberate. Goods a business buys in the shop and takes to another member state could be an exempt intra-Community supply, but only with proof that they arrived there (§ 6a UStG), and a counter sale produces none. Such a sale is taxed in the shop's country.
A sale taken on a card reader passes the reader's country for you: see Payment in person.
A zero always says why
SaleTaxFacts carries a reason beside the amount whenever a sale carried no tax:
| Reason | What it means | Belongs in a return |
|---|---|---|
reverse_charge | the buyer accounts for the tax instead of you | yes, on both sides |
supplied_outside_the_union | the supply is placed outside the union, so no member state's tax is due | no |
The two produce the same amount and nothing else about them is the same, which is the whole reason the field exists. Read only as a number, a supply placed outside the union is indistinguishable from an intra-union B2B one — and from an invoice that simply failed to charge tax. An exemption a document cannot name is one it cannot claim, so the reason travels with the decision rather than being inferred later from a zero.
Two things it deliberately does not cover. A free supply is not exempt — there is no taxable amount to relieve, so the reason stays empty rather than putting an exemption note on a document with nothing to exempt. And a nil rate a country grants for a category of goods is not an exemption either: that is a rate of zero on a taxable supply, and it still belongs in a return, so folding it in here would let it adopt the treatment of a supply that must stay out of one.
A paid credit top-up is taxed when it is spent, not when it is bought
An add-on that grants no usage units credits the owner's money balance at face value. That is a voucher: money against a promise, with neither the place nor the rate of the eventual supply decided. The hosted checkout therefore does not ask the provider to tax it. The invoice that balance later pays carries its own tax, and taxing the top-up as well charges the same money twice — on a credit, which cannot be corrected out afterwards.
Where you sell into exactly one country at one rate, the supply is determined when the money changes hands. Say so once — with the voucher setting, rather than with a second one of its own, so the same question cannot be answered two ways — and a top-up is taxed at the till again:
BILLING_VOUCHER_INSTRUMENT_TYPE=single_purpose
The setting and what it freezes are in the configuration reference.
An add-on that grants usage units is unaffected: it buys something now, and it is taxed now.
What the top-up counts toward, and the one thing it rests on
Both instruments feed the same rolling figure: billing:vouchers:volume measures the value that has
gone into vouchers and into paid credit over the window, against the volume threshold in the
configuration reference, and announces each level as it is reached. So an installation that sells credit and issues no
vouchers at all is not outside the counter — reading it that way is how a threshold gets crossed eleven months
before anyone notices. Refunds come off, and currencies are counted apart rather than converted. Each purchase
records whether it put money on the balance, so renaming a credit add-on or taking it out of sale does not take its
earlier purchases out of the figure.
The comfortable position here rests on one fact, and it is yours to keep true: the balance is redeemed where it was issued. A balance a customer spends with you, against what you supply, is a closed thing. The moment it can be spent with a third party, cashed out, or handed on, the question changes into a different one entirely — and this package neither arranges that nor detects it, because how far a balance reaches is a product decision, not a schema. It says so here rather than leaving the silence to be read as a clearance.
Reselling second-hand goods on the margin
A reseller of second-hand goods can be taxed on the margin rather than on the price: the difference between what they paid for the goods and what they sold them for. The tax is contained in that difference and never appears on the document. The package does not issue such a document itself. What it gives a reseller who issues one is the arithmetic and the refusals.
Whether a sale may use the scheme. MarginSchemeAvailability::assertPermitted() refuses two cases, each
with its own message. One is a sale on which the platform is treated as the supplier
(platform_deemed_supplier). The other is goods that nobody supplied to the seller for a price. Having once
been private property is not the test: goods bought from a private person for a price are exactly what the
scheme is for.
The margin, and the tax inside it. MarginTaxation::marginOn() floors a loss at zero, and taxOn() takes
the tax out of the margin at the standard rate. Never the goods' own reduced rate: the scheme taxes the dealing,
not the thing sold.
The document. Freeze the basis and the margin onto the record when it is issued:
use Pushery\Billing\Enums\TaxationBasis;
use Pushery\Billing\Models\InvoiceRecord;
use Pushery\Billing\Tax\MarginTaxation;
$margin = app(MarginTaxation::class)->marginOn($purchase, $sale);
InvoiceRecord::query()->create([
// the owner, the number, the buyer and the lines, as for any document
'total_minor' => $sale->minorUnits,
'subtotal_minor' => $sale->minorUnits,
'tax_minor' => 0,
'taxation_basis' => TaxationBasis::Margin,
'margin_minor' => $margin->minorUnits,
]);
A margin-taxed record that states a tax amount is refused when it is created. The seller would owe that amount
on top of the tax on the margin, and a frozen document cannot be corrected afterwards, only canceled. The
rendered document prints no tax and no rate. It prints the prescribed wording when your jurisdiction profile
supplies it through SuppliesMarginSchemeWording, which the German profile does. In XRechnung and ZUGFeRD the
document is category E with the exemption code VATEX-EU-F, and the same wording is its exemption reason. A
margin-taxed document that also names an exemption, or whose lines carry a tax rate, is refused rather than
rendered.
Refunds. MarginRefundCorrector::correctionFor() gives back the tax on the margin, not the tax on the
price, and a partial refund lowers the margin by the whole refunded amount rather than by a share of it. It
refuses a document that was not taxed on the margin, and one without a margin_minor. A reseller who works out
the margin over a whole period rather than item by item has no margin per sale to freeze: leave the column
empty, and correct that period's total instead.
Not covered yet. Only the second-hand goods wording ships. Works of art, and collectors' items and antiques, each have their own prescribed wording and their own exemption code, and a margin-taxed document for them would carry the wrong ones.
← Back to the documentation index
A locally billed subscription cycle states its tax only once its basis exists
Under provider mode the provider determines the tax and the package copies the result. Under a local
engine there is no such result, and every invoice a cycle raised left tax_minor null — honest, and
not a complete document for a cycle that triggers VAT.
The cycle now determines it, from four things that must all be established. Any one of them missing and the document is written exactly as before: a null tax, a subtotal equal to the total, and no characteristics. Null says nobody established this; zero would say none was due.
1. The tier says what kind of supply it is
A subscription is not automatically an electronically supplied service — one that includes a live session, a shipped item or a membership is placed under a different rule. So the archetype is a property of the product:
'tiers' => [
'pro' => [
'label' => 'Pro',
'archetype' => 'subscription',
],
],
An unset archetype refuses. A default here would be a guessed tax treatment frozen onto an immutable document, and it would be right often enough that nobody would find the times it was not.
2. You record where the customer is, once, at signup
The evidence for a customer's country — what they declared, where their instrument is issued, where their connection was — exists only in the moment of the sale. A cycle is billed by the scheduler weeks later, with nothing fresh to read. There is nothing to derive from at that point except a stored address, and deriving from that would let a customer who has since moved change what an old cycle was taxed under.
So record it when the subscription is established, under the reference the package reads back:
$ipCountry = app(IpCountryResolver::class)->countryFor($request->ip());
app(SupplyPlaceDecision::class)->decide(
new CountrySignals(declared: $form->country, payment: $mandate->country, ip: $ipCountry),
OrderTaxBasis::placeReferenceFor($subscription),
$subscription->currency,
now(),
$owner,
);
$ipCountry comes from the IpCountryResolver you bind, asked inside the request. The address goes in as
an argument and a two-letter country comes out, so the address reaches nothing else. The package ships no
geolocation: its default, NullIpCountryResolver, answers null, which leaves that signal out rather than
guessing one. Bind your own resolver in a service provider to have the signal at all.
Use OrderTaxBasis::placeReferenceFor() rather than spelling the string yourself. A reader and a writer
that agree by convention are two strings that eventually differ, and the failure is silent: every cycle
simply finds nothing and issues a document with no tax.
decide() refuses evidence that did not settle on a country, and that refusal belongs at signup where
you can still ask the customer. How many agreeing signals count as settled is
billing.tax_evidence.required_signals — two by default.
3. The cycle covers a period
Taken from the order. The document states the last day covered, which is a day before the subscription's own period end — that end is the next period's first day.
4. The customer's tax status, where a register has confirmed it
A verified TaxIdVerification for the owner makes the cycle a business supply, placed where the id registers
the business. The id a business gives names the establishment the service goes to (Article 22(1) of
Implementing Regulation (EU) No 282/2011), so its country decides, not the place evidence from step 2, which
answers where a consumer is. A union VAT id reads its country from its prefix, with EL as GR and XI as
GB, and any other type from its name, so gb_vat is the United Kingdom.
- A business in another member state is zero-rated, with
reverse_chargeset andtax_exemption_reasonstating why. - A business in the seller's own country pays the domestic rate.
- A business outside the union is outside the union's tax, stated as supplied outside the union.
An id whose country cannot be read proves no place and is treated as unproven. Without a verified id the customer is treated as a consumer, which is the safe direction — over-charging is corrected with a credit note, under-charging VAT is a liability. That includes a business in the union that gives no VAT id at all: a customer in the union who communicated no VAT number may be regarded as a non-taxable person (Art. 18(2) of Implementing Regulation (EU) No 282/2011), so the sale is taxed at its destination and reported under the consumer scheme. A business outside the union that gives none stays outside the union's tax.
What ends up on the document
tax_minor, subtotal_minor and tax_rate_bps, plus the basis beside them: tax_archetype,
place_of_supply_rule, tax_rate_category, tax_exemption_reason, destination_country,
destination_subdivision, reverse_charge, tax_exempt, oss, and the two service-period dates.
The basis is not decoration. A driver that does not determine tax may state a figure only if the document records how it was reached, and that is enforced rather than encouraged: an invoice with a tax and no basis is refused when it is saved.
The rate is derived from the two amounts rather than looked up, so it is the rate the customer was actually charged. On small amounts that rounds: 20.00 gross at 20 % splits into 16.67 and 3.33, and the quotient of those is 19.98 %. What never moves is that the parts add back to the total.