Skip to content

Powered by Grav CMS + Helios Theme

Tax drivers

A tax driver quotes tax for one region through an outside service instead of KahunaCart's own rate table, and it can take over the zone and rate editors while it does. This page is for store administrators deciding whether to connect a tax service, and for the developer writing the connector.

KahunaCart ships this contract and no driver. There is no Avalara, TaxJar or Stripe Tax engine in this plugin. The connectors belong to the tax and VAT add-on, grav-plugin-kahunacart-tax. The only engine in the repository is the TestTaxEngine fixture the acceptance tests drive, and that is the starting point to copy.

Binding is per region, never per store

An engine is assigned to a tax zone. This lets one store use an external connector for the United States and its own EU VAT rates for Europe.

TXT
PUT /kahunacart/tax/engines/bind
{ "zone_id": 4, "engine": "my-tax-service" }

Send an empty engine to unbind. GET /kahunacart/tax/engines lists everything registered, the store-level default, what the built-in editors still govern, and the registered tax-id validators.

A zone that names no engine falls back to the store-level default, the tax.engine config key. It defaults to '', meaning no default engine and the store's own rates. It sits in the settings form beside tax.basis and tax.included.

If a zone's engine is no longer registered, for example because its plugin is disabled, the zone falls back to your own rates. The admin identifies affected zones.

The engine interface

PHP
namespace Grav\Plugin\KahunaCart\Tax\Engine;

interface TaxEngineInterface
{
    public function code(): string;
    public function label(): string;
    public function quote(TaxContext $context): TaxQuoteResult;

    public function providesRateAdministration(): bool;
    public function adminNavItems(): array;
    public function adminActionHtml(): ?string;
}
Method What it answers
code() The stable code a zone's engine column holds and the bind route names. Lowercase letters, digits and dashes
label() A language key naming the engine in the admin, or a plain name. The admin prints an unresolvable key verbatim, so a driver with no translations returns its own name
quote() The tax for one order. May fail
providesRateAdministration() Whether the built-in zone, class and rate editors still apply once this engine is bound. See Replacing the admin
adminNavItems() Items to put where the built-in editors were, each ['id' => …, 'label' => …, 'href' => …]
adminActionHtml() A block of admin markup rendered in place of the rate table, such as a Sync button, an account status, or a link to the service's own dashboard. It is rendered as trusted markup, so escape at the driver

Registering one

PHP
public function onPluginsInitialized(): void
{
    $this->enable(['onKahunaCartRegisterTaxEngines' => ['onRegisterTaxEngines', 0]]);
}

public function onKahunaCartRegisterTaxEngines(Event $event): void
{
    $event['registry']->register(new MyTaxEngine($this->config), 'my-tax-plugin');
}

First registration of a code wins and a duplicate is refused by name, as in every registry in this plugin. The second argument is your plugin's name, and the refusal message names it.

What a driver is handed

TaxContext and nothing else. A driver never sees the calculation, so it cannot write a discount, reprice a line or take the postage off.

Field What it holds
address country, region, postcode, city - the address the store taxes on, chosen by tax.basis or by the collection point
lines One TaxContextLine per cart line: the order item id, the frozen tax class, the quantity, the unit price, the discounted line total, the shipping apportioned to it, whether it is a download, and the inclusivity in force for it
shippingTotalMinor The order's whole shipping total, already netted of any free-shipping coupon
currency ISO 4217
pricesIncludeTax The inclusivity in force for the order, already worked out through the resolution order
region The tax zone row this quote is for. regionId() and regionName() are the two parts a driver normally wants
basis shipping or billing, for the audit trail

Every amount is minor units of the order currency. Nothing in the context identifies a customer.

What a driver answers with

TaxQuoteResult: a list of typed TaxLine objects plus the driver's own reference for the whole quote. That is Avalara's transaction id, TaxJar's order hash, or whatever the service will show you when you ring up about a return.

PHP
return new TaxQuoteResult(
    [
        new TaxLine(
            subject: TaxSubject::ORDER_TAXABLE_TOTAL,
            itemId: null,
            amountMinor: 812,
            rate: '8.25',
            label: 'CO State Sales Tax (8.25%)',
            included: false,
            source: ['service_rate_id' => 'CO-DEN-001', 'jurisdiction' => 'US-CO-DENVER'],
        ),
    ],
    reference: 'txn_9f2c1e',
    meta: ['engine_version' => '2026.09'],
);

subject is one of the six taxable subjects and decides where the adjustment lands. A line subject carries the itemId it was worked out from, and an order subject passes null. rate is a decimal string, never a float, so Finland's 25.5 reaches the cents intact. included says the amount is already inside the price: the row is written but does not move the total.

An empty result is a real answer, not a failure. A zero-rate jurisdiction writes no adjustment rather than a 0.00 row on every receipt.

What reaches the order

One tax adjustment per non-zero line, carrying a snapshot naming the engine, the region and the driver's own reference:

JSON
{
  "engine": "my-tax-service",
  "engine_label": "My Tax Service",
  "zone_id": 4,
  "zone_name": "United States",
  "reference": "txn_9f2c1e",
  "subject": "order_taxable_total",
  "rate": "8.25",
  "included": false,
  "basis": "shipping",
  "driver": { "service_rate_id": "CO-DEN-001" },
  "quote_meta": { "engine_version": "2026.09" },
  "address": { "country": "US", "region": "CO", "postcode": "80202", "city": "Denver" }
}

driver is your own source array, kept whole and never merged into the top level, so a key you invent cannot collide with one the plugin relies on.

Replacing the admin

A store that has bound TaxJar must not be shown a rate table that no longer does anything. An engine declaring providesRateAdministration(): false takes three things at once:

  • the zone editor, the class editor and the rate editor disappear from the admin, replaced by the engine's own nav items and action HTML
  • the built-in rate administration stops being granted, which the admin reads off the API
  • POST, PATCH and DELETE on /kahunacart/tax/zones, the EU VAT preset import and the CSV import all answer 409 with a message naming the engine

PUT /kahunacart/tax/engines/bind is left out of that list. It is the route that undoes a binding, so a store can always unlock its own rate table.

Three states, because binding is per region

State What it means What the admin does
full Nothing is bound, or everything bound administers its own rates The editors behave exactly as they always have
partial Some zones are quoted by a non-administering engine and some are not The editors stay, and the screen says which regions they still govern
none Every zone is quoted by a non-administering engine, or the store has no zones and the store-level default is one The editors go and the routes answer 409

partial exists so a rate table that still governs four of nine zones is not hidden.

When a driver fails

A driver that fails does not take checkout down. The tax stage catches the failure, emits nothing for that region, and the order is placed with no tax. What the store does about the shortfall is your decision. Log it from your driver, where you know what failed.

Tax-id validators

PHP
interface TaxIdValidatorInterface
{
    public function code(): string;
    public function label(): string;
    public function countries(): array;
    public function validate(string $countryCode, string $taxId): TaxIdValidationResult;
}

TaxIdValidationResult carries valid, checkedAt, name, address and raw.

Nothing in KahunaCart implements this interface. VAT-number capture, validation and reverse charge is the tax and VAT add-on's feature, and the comparison matrix records that capability as absent from core. A test scans the plugin's classes on every run and fails if anything in core implements either this or TaxEngineInterface.

The add-on is built from this interface plus two seams core already has:

  • the checkout field that collects the number is an ordinary checkout field registered on onKahunaCartRegisterCheckoutFields
  • the zero-rating it applies is an ordinary tax engine bound to the regions it covers
  • the validator itself registers through onKahunaCartRegisterTaxIdValidators

The two things an implementation has to get right

A service being down is not an invalid number. Treating a VIES timeout as "invalid" would charge VAT to a company entitled to zero-rating. Fail on a transport error and let the caller decide. Return TaxIdValidationResult::invalid() only when the authority actually said no.

Cache with the timestamp. Every one of these services rate-limits, and checked_at is what makes a cached answer re-checkable.

A validator declaring no countries answers for all of them. One naming countries is preferred over one that does not, so a store running both an HMRC plugin and a VIES plugin gets the specialist for the United Kingdom and the generalist everywhere else.

A worked driver

The fixture the acceptance tests drive quotes a flat percentage per address, carries a reference, can be told to replace the admin, and can be told to fail. Copy it and replace quote() with your service call.

PHP
final class MyTaxEngine implements TaxEngineInterface
{
    public function code(): string
    {
        return 'my-tax-service';
    }

    public function label(): string
    {
        return 'PLUGIN_MYTAX.ENGINE_LABEL';
    }

    public function quote(TaxContext $context): TaxQuoteResult
    {
        $response = $this->client->tax([
            'to' => $context->address,
            'currency' => $context->currency,
            'shipping' => $context->shippingTotalMinor,
            'lines' => array_map(static fn (TaxContextLine $line): array => [
                'id' => $line->id,
                'class' => $line->taxClass,
                'quantity' => $line->quantity,
                'amount' => $line->lineTotalMinor,
            ], $context->lines),
        ]);

        return new TaxQuoteResult(
            array_map(static fn (array $row): TaxLine => new TaxLine(
                TaxSubject::ORDER_TAXABLE_TOTAL,
                null,
                (int)$row['amount_minor'],
                (string)$row['rate'],
                (string)$row['name'],
                $context->pricesIncludeTax,
                ['service_rate_id' => $row['id']],
            ), $response['lines']),
            (string)$response['transaction_id'],
        );
    }

    public function providesRateAdministration(): bool
    {
        return false;
    }

    public function adminNavItems(): array
    {
        return [['id' => 'sync', 'label' => 'Sync from My Tax Service', 'href' => '#/section/my-tax-sync']];
    }

    public function adminActionHtml(): ?string
    {
        return '<button type="button" data-my-tax-sync>Sync now</button>';
    }
}

Configuration

Key Default Effect
tax.engine '' The store-level default engine every unbound zone falls back to. Empty means the store's own rate table