Skip to content

A webshop checkout

A webshop checkout

A basket is a document, not a pile of supplies: the lines share a buyer, a seller and a date, the shipping is apportioned across them, and the totals have to reconcile to what the customer is charged. TaxOrder is that document.

use Brick\Money\Money;
use Cbox\Geo\Contracts\JurisdictionRepository;
use Cbox\Geo\ValueObjects\CountryCode;
use Cbox\Tax\Contracts\OrderTaxCalculator;
use Cbox\Tax\Enums\{CustomerType, Pricing, TaxClass};
use Cbox\Tax\ValueObjects\{SellerRegistrations, SupplyLine, TaxOrder};

$geo = app(JurisdictionRepository::class);

$order = new TaxOrder(
    place: $geo->find(new CountryCode('DE')),        // where the buyer is
    customer: CustomerType::Consumer,
    seller: $tenant->taxRegistrations(),             // this shop's own entity
    pricing: Pricing::Inclusive,                     // shelf prices include VAT
    lines: [
        new SupplyLine('sku-101', Money::of('49.90', 'EUR'), TaxClass::GeneralGoods, quantity: 2),
        new SupplyLine('sku-204', Money::of('19.00', 'EUR'), categoryKey: 'goods.publications.book'),
        new SupplyLine('shipping', Money::of('4.95', 'EUR'), isDeliveryCharge: true),
    ],
    suppliedAt: $cart->placedAt,      // the tax point: when the supply was made
);

$assessment = app(OrderTaxCalculator::class)->assessOrder($order);

Three things in there are worth pointing at.

quantity is how many items the amount covers. amount is always the extended amount — unit price × quantity, less any discount — and the quantity exists so that per-item caps work. Massachusetts exempts clothing up to $175 per article, so two $150 coats on one line are two exempt coats, not one $300 taxable supply.

Most products need no category. A shop's own catalogue has thousands of product types and the tax law has a few hundred categories, and most products fall in none of them: they are general goods, taxed at the standard rate. That is the default — TaxClass::GeneralGoods — and it is not a guess and carries no flag. A category is needed only for a product the law rates differently: food, books, medicine, children's clothing, a service such as education. Leaving one of those out prices it at the standard rate, which over-charges rather than under-charges — and nothing in the engine notices: which of your products are among them is the one thing only the shop can say.

categoryKey asks the register in its own words. TaxClass covers the common cases; the register publishes 182 categories, and a key reaches the ones the enum cannot name — goods.publications.book, services.education, goods.medical_equipment.prosthetic. An unknown key refuses and names the nearest published ones rather than quietly pricing as general goods.

Give the product its commodity code if you have it. A category finds the candidate rates; the conditions on those rates decide which one applies. The United Kingdom zero-rates agricultural inputs only for seeds and food animals, so fertiliser filed there needs its CN code to come back at 20% rather than 0%, flagged. The code customs already needs settles every condition written in tariff terms, in every market — see rate conditions.

Shipping is a line, flagged as one. It is assessed after the goods, because what freight is taxed at depends on what it delivered. Where the goods carry several rates, the freight is split between them by value.

Reading the result

$assessment->net();                   // Money — the sum of the lines
$assessment->tax();                   // summed from the rounded lines, never recomputed
$assessment->gross();
$assessment->forLine('sku-204');      // that line's own assessment, with its own rate
$assessment->taxByAuthority();        // per-jurisdiction totals for a remittance, or null

Before the order goes out

if ($assessment->needsReview()) {
    foreach ($assessment->limits() as $limit) {
        report($limit->value.' — '.$limit->remedy());
    }
}

needsReview() is true when anything on the document is less than authoritative: a rate resolved from a broader category, a US service taxed because nothing published says otherwise, an address that only reached the state line, a marketplace mandate whose conditions were not evaluated. Every limit carries remedy() — the one step that would close it — and callerCanClose() says whether that step is yours.

Blocking the checkout on it is a policy decision. Blocking on it is defensible for a first sale into a new country; for a long tail of flagged-but-fine lines, recording the flag on the order and reviewing the list weekly usually is not worse.

What to store on the order

Store enough to explain the number a year from now, when both the rates and this package have moved on:

Store From Why
treatment, net, tax, gross the assessment what you charged
the reason string $line->reason the sentence for the accountant
release version $line->rate?->provenance?->version which published data answered
effective date, section hash $line->rate?->provenance?->effectiveFrom, ?->sectionHash from when the rate applied, and the exact bytes it was read from
confidence and limitedBy $line->rate whether it was exact, and what was missing
the seller registrations used your tenant record the other half of the calculation

With the release version stored you can reproduce any historical invoice exactly: install that release, pin it, and re-price. See backfilling old invoices.