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 configured — TaxResolutionService 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: Stringregion_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.