Skip to content

Per-Country/Region Tax System

Overview

Tax is applied at invoice creation time, stored as a snapshot on the invoice. The amount field stays as the pre-tax subtotal; tax_amount is additive, and total_amount = amount + tax_amount.

A user's billing country is stored as billing_country_code on their profile (ISO 3166-1 alpha-2). The tax_rates table supports region-level granularity for future expansion.

Zero-tax when no rate is configuredTaxResolutionService returns ZERO if no applicable rate exists.

Phase 1: Migrations

2026-03-21-000001_tax_rates

CREATE TABLE tax_rates (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    country_code    CHAR(2)          NOT NULL,
    region_code     TEXT,
    rate            NUMERIC(6, 4)    NOT NULL,
    label           TEXT             NOT NULL,
    effective_from  TIMESTAMPTZ      NOT NULL,
    effective_until TIMESTAMPTZ,
    created_at      TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    CONSTRAINT tax_rates_rate_positive CHECK (rate >= 0),
    CONSTRAINT tax_rates_rate_max      CHECK (rate <= 1)
);

CREATE INDEX idx_tax_rates_lookup ON tax_rates (country_code, region_code, effective_from DESC);

2026-03-21-000002_invoice_tax_fields

ALTER TABLE invoices
    ADD COLUMN tax_rate_id  UUID REFERENCES tax_rates(id),
    ADD COLUMN tax_rate     NUMERIC(6, 4),
    ADD COLUMN tax_label    TEXT,
    ADD COLUMN tax_amount   NUMERIC(12, 4);

ALTER TABLE users
    ADD COLUMN billing_country_code CHAR(2);

Phase 2: Domain Layer

TaxRate entity

  • id: TaxRateId (UUID)
  • country_code: String
  • region_code: Option<String>
  • rate: Decimal (e.g. 0.2000 = 20%)
  • label: String (e.g. "VAT", "GST")
  • effective_from: DateTime<Utc>
  • effective_until: Option<DateTime<Utc>>None = currently active
  • Methods: is_active_at(at), apply_to(subtotal) -> Decimal

TaxRateRepository

async fn find_applicable(country_code, region_code, at) -> Option<TaxRate>
async fn find_by_id(...)
async fn list_all(...)
async fn save(...)
async fn delete(...)

Invoice fields added

pub tax_rate_id: Option<TaxRateId>,
pub tax_rate: Option<Decimal>,
pub tax_label: Option<String>,
pub tax_amount: Option<Decimal>,

pub fn total_amount(&self) -> Decimal {
    self.amount + self.tax_amount.unwrap_or(Decimal::ZERO)
}

Phase 3: Infrastructure Layer

find_applicable SQL logic

SELECT * FROM tax_rates
WHERE country_code = $1
  AND (region_code = $2 OR region_code IS NULL)
  AND effective_from <= $3
  AND (effective_until IS NULL OR effective_until > $3)
ORDER BY region_code NULLS LAST, effective_from DESC
LIMIT 1

Region-specific rows sort first (NULLS LAST); country-wide is the fallback.

Phase 4: Application Layer

TaxResolutionService

pub struct TaxResolution {
    pub tax_amount: Decimal,
    pub tax_rate: Option<Decimal>,
    pub tax_label: Option<String>,
    pub tax_rate_id: Option<TaxRateId>,
}

pub trait TaxResolutionService: Interface {
    async fn resolve_tax(
        &self,
        user_id: &UserId,
        subtotal: Decimal,
        at: DateTime<Utc>,
    ) -> exn::Result<TaxResolution, TaxResolutionError>;
}

Fetches user's billing_country_code, calls tax_rate_repo.find_applicable(country, None, at).

CreateOrderService, ResizeOrderService, and ProcessBillingCyclesTask all inject this service.

PriceBreakdown

pub struct PriceBreakdown {
    // ... existing fields ...
    pub tax_amount: Decimal,
    pub tax_rate: Option<Decimal>,
    pub tax_label: Option<String>,
    pub total_price: Decimal,    // final_price + tax_amount
}

Phase 5: HTTP Layer

Admin tax-rates controller

Permission: ManageInvoices

Method Path Description
GET /api/v1/admin/tax-rates List all rates
POST /api/v1/admin/tax-rates Create rate
GET /api/v1/admin/tax-rates/{id} Get rate
PATCH /api/v1/admin/tax-rates/{id} Update effective_until / label only
DELETE /api/v1/admin/tax-rates/{id} Delete (if no invoices reference it)

Create request body: - country_code: String — exactly 2 chars, uppercase - region_code: Option<String> - rate: Decimal (0.0 – 1.0) - label: String - effective_from: DateTime<Utc> - effective_until: Option<DateTime<Utc>>

Design Decisions

Snapshot on invoice — both tax_rate_id (FK for traceability) and snapshotted decimal values are stored. Changing a tax rate record later must not alter historical invoice display.

amount stays as subtotal — existing price comparison, discount, and renewal cost logic is untouched. Only the payment gateway needs updating to charge total_amount().

ManageInvoices permission — no new permission variant needed.