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).
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 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.
⚠️ 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:
app(SupplyPlaceDecision::class)->decide(
new CountrySignals(declared: $form->country, payment: $mandate->country, ip: $ipCountry),
OrderTaxBasis::placeReferenceFor($subscription),
$subscription->currency,
now(),
$owner,
);
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 validated business supply: zero-rated,
reverse_charge set, and tax_exemption_reason stating why. Without one 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.
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.