Tax & VIES

Per-(category × market) tax rates expressed in basis points, plus VIES validation for B2B EU customers with reverse-charge handling.

Tax categories

A category classifies a product for tax purposes. Common codes (account-defined):

CodeCzech nameTypical rate
standardDPH 21%21%
reducedDPH 12%12%
super_reducedDPH 0%0%
zeroBez DPH0%
exemptOsvobozeno
curl -X POST "$API/tax/categories.json" \
  -H "Authorization: Bearer $NEVIOS_KEY" -H "Content-Type: application/json" \
  -d '{"code":"standard","name":"DPH 21 %","is_default":true}'

Each variant is implicitly mapped via its tax_rate_bps. (Explicit mapping table coming in a future iteration.)

Tax rates

Rates are basis points: 2100 = 21.00%. Per (category × market).

curl -X POST "$API/tax/rates.json" \
  -H "Authorization: Bearer $NEVIOS_KEY" -H "Content-Type: application/json" \
  -d '{
    "tax_category_id": 308...,
    "market_id": 308...,
    "rate_bps": 2100,
    "display_name": "DPH 21 %"
  }'

When you set a new rate, the previous active one for that (category, market) is auto-closed (effective_to = now()) — no overlap. The historical row stays for reporting.

find_active_rate(category, market, when) walks the timeline so a 3-year-old order can still report its original tax rate even if the current rate is different.

VIES (B2B reverse charge)

The vat_validations table is an audit log of every VIES check. Sources: vies (live), manual (staff override), cached (we trusted a recent cached result).

curl -X POST "$API/tax/vat/validate.json" \
  -H "Authorization: Bearer $NEVIOS_KEY" -H "Content-Type: application/json" \
  -d '{
    "vat_country_code": "CZ",
    "vat_number": "12345678",
    "customer_id": 308...,
    "use_cache": true
  }'
{
  "vat_validation": {
    "id": "308...",
    "vat_country_code": "CZ",
    "vat_number": "12345678",
    "valid": true,
    "registered_name": null,
    "registered_address": null,
    "consultation_number": null,
    "source": "vies",
    "validated_at": "2026-05-01T10:30:00Z"
  }
}

Phase 1 stub: the live VIES SOAP call is currently mocked — it returns valid: true for syntactically-valid EU country VAT numbers and false otherwise. A real SOAP client lands in the next iteration.

The cache window is configurable per account (commerce.tax.vies_cache_days setting, default 7 days). Cache hits return the previous validation row instead of re-checking.

Reverse charge at checkout

When the customer enters a VAT number at checkout (PUT /storefront/2026-01/checkouts/{token}/vat.json), the checkout service:

  1. Calls validate_vat_number (cache → live)
  2. Sets vat_validation_id
  3. Computes reverse_charge = (valid AND non-domestic AND commerce.tax.b2b_reverse_charge=true)
  4. Sets tax_exempt = reverse_charge
  5. Recomputes totals — VAT zeroed, prices presented net

Czech B2B sale within CZ → reverse charge off (domestic). Czech B2B sale to a German VAT-registered customer → reverse charge on (EU non-domestic + valid VAT).

Fail-open with cache

If VIES SOAP is down (real outages happen) and there's no cache hit, we honor the storefront's use_cache: true setting and fail open — the validation record gets source='cached' flagged so accountants can re-verify manually later. This protects revenue during VIES outages without compromising audit.

Endpoints

POST /admin/2026-01/{handle}/tax/categories.json
POST /admin/2026-01/{handle}/tax/rates.json
GET  /admin/2026-01/{handle}/tax/rates.json?active_only=true
POST /admin/2026-01/{handle}/tax/vat/validate.json

Next: Carts API.