Current tax logic (main/services/tax.ts) hardcodes two countries:
- India — fixed 5% for restaurant/salon, CGST+SGST split intra-state, IGST inter-state.
- Thailand — flat 7% VAT.
- Everything else — falls back to a single flat rate off
product.tax_rate, no real compliance logic.
This won't scale as we add countries. Before writing more if (tenant.country === 'XX') branches, want to align on an approach — so did some homework on how other systems handle this, and where the open debates are.
How other software handles it
Odoo — taxes are data, not code. A tax is a record (name, rate, computation type: fixed/percent/division, inclusive or exclusive) attached to products or fiscal positions. Countries ship as localization modules that just install a default set of tax records + a chart of accounts for that country, rather than a special-cased code path per country. It also supports compound taxes — one tax computed on the result of another, which is exactly the shape a surcharge needs (see cess example below). (Odoo tax docs, Odoo tax customization)
Open Source POS (opensourcepos) — products belong to a tax category, and each category maps to one or more rates. That's the same "category, not product field" pattern you'd want for exemptions/reduced rates (e.g. unprepared food vs. alcohol vs. electronics) instead of a single tax_rate column per product. (opensourcepos wiki)
Open Sales Tax (self-hosted US sales tax API) — takes line items with a category field ("general", "clothing", etc.) and returns per-line jurisdiction + rate. Confirms category-based taxability, not product-based, is the norm once you leave flat-VAT countries. (open-sales-tax)
Stripe Tax / Avalara / TaxJar — the commercial end of this. Stripe assigns each product a tax category from a fixed taxonomy and a tax behavior (inclusive/exclusive/automatic), then resolves the actual rate against the customer's jurisdiction at calculation time — the product never stores a rate. TaxJar/Avalara go further with address-level ("rooftop") jurisdiction resolution for the US, where rates vary by street address, not just by state. That's out of scope for a self-hosted POS, but the category on the product, rate resolved externally separation is worth borrowing. (Stripe tax categories, tax engine comparison)
Surcharge / cess = compound tax, not a flat add-on
India's Health & Education Cess is the clean example: it's 4% of (tax + surcharge), not 4% of the sale amount. E.g. tax ₹50,000 + surcharge ₹10,000 → cess = 4% × ₹60,000 = ₹2,400. That means a surcharge/cess can't be modeled as another line at computeTaxAmount(taxType, taxableAmount, rate) against the subtotal — it needs to run against the tax already computed, i.e. a tax that takes another tax's output as its base. (cess vs surcharge, compounding example)
Current calculateItemTax has no way to express "tax on a tax" — every branch computes straight off taxableAmount. Any surcharge support needs that as a base capability, not a per-country special case.
The two real debates
-
Config-driven vs. pluggable-module vs. hardcoded-branch. Odoo's answer is "tax = data row, country = a module that seeds default data." The opposite extreme (what we have now) is a switch statement per country in application code. The middle ground — a small rules engine (base rate → optional compound surcharge → category override → inclusive/exclusive) driven by settings, with countries just supplying default config — seems to fit a self-hosted single-tenant POS better than full pluggable modules, but worth debating before committing.
-
Inclusive vs. exclusive display. Most VAT/GST countries require tax-inclusive shelf/menu pricing; US-style sales tax is exclusive by convention (add at checkout). tax_type already exists per product, so this is less of an open question for us — but it means the default for new countries needs to be right out of the box (e.g. defaulting to exclusive for a new EU country would be a compliance bug, not just a UX one). (inclusive pricing overview)
Open questions for contributors
- Do we model surcharge/cess as a new
tax_type: 'compound' that references a base tax's computed amount, or a separate surcharges table keyed off tax config?
- Move
tax_rate off products entirely and onto a tax_category (food / alcohol / electronics / exempt / …), with category→rate resolved per country/state at calc time?
- How many countries do we actually need in v1 beyond IN/TH — is there a shortlist (US, EU/VAT, UK, SG, etc.) worth targeting first?
- Where does per-state/province variation (US sales tax, Canadian GST/PST) fit — is that in scope for a self-hosted single-location POS, or do we punt on it explicitly?
Related code: tax.ts, countries.ts
Current tax logic (
main/services/tax.ts) hardcodes two countries:product.tax_rate, no real compliance logic.This won't scale as we add countries. Before writing more
if (tenant.country === 'XX')branches, want to align on an approach — so did some homework on how other systems handle this, and where the open debates are.How other software handles it
Odoo — taxes are data, not code. A tax is a record (name, rate, computation type: fixed/percent/division, inclusive or exclusive) attached to products or fiscal positions. Countries ship as localization modules that just install a default set of tax records + a chart of accounts for that country, rather than a special-cased code path per country. It also supports compound taxes — one tax computed on the result of another, which is exactly the shape a surcharge needs (see cess example below). (Odoo tax docs, Odoo tax customization)
Open Source POS (opensourcepos) — products belong to a tax category, and each category maps to one or more rates. That's the same "category, not product field" pattern you'd want for exemptions/reduced rates (e.g. unprepared food vs. alcohol vs. electronics) instead of a single
tax_ratecolumn per product. (opensourcepos wiki)Open Sales Tax (self-hosted US sales tax API) — takes line items with a
categoryfield ("general", "clothing", etc.) and returns per-line jurisdiction + rate. Confirms category-based taxability, not product-based, is the norm once you leave flat-VAT countries. (open-sales-tax)Stripe Tax / Avalara / TaxJar — the commercial end of this. Stripe assigns each product a tax category from a fixed taxonomy and a tax behavior (inclusive/exclusive/automatic), then resolves the actual rate against the customer's jurisdiction at calculation time — the product never stores a rate. TaxJar/Avalara go further with address-level ("rooftop") jurisdiction resolution for the US, where rates vary by street address, not just by state. That's out of scope for a self-hosted POS, but the category on the product, rate resolved externally separation is worth borrowing. (Stripe tax categories, tax engine comparison)
Surcharge / cess = compound tax, not a flat add-on
India's Health & Education Cess is the clean example: it's 4% of (tax + surcharge), not 4% of the sale amount. E.g. tax ₹50,000 + surcharge ₹10,000 → cess = 4% × ₹60,000 = ₹2,400. That means a surcharge/cess can't be modeled as another line at
computeTaxAmount(taxType, taxableAmount, rate)against the subtotal — it needs to run against the tax already computed, i.e. a tax that takes another tax's output as its base. (cess vs surcharge, compounding example)Current
calculateItemTaxhas no way to express "tax on a tax" — every branch computes straight offtaxableAmount. Any surcharge support needs that as a base capability, not a per-country special case.The two real debates
Config-driven vs. pluggable-module vs. hardcoded-branch. Odoo's answer is "tax = data row, country = a module that seeds default data." The opposite extreme (what we have now) is a switch statement per country in application code. The middle ground — a small rules engine (base rate → optional compound surcharge → category override → inclusive/exclusive) driven by settings, with countries just supplying default config — seems to fit a self-hosted single-tenant POS better than full pluggable modules, but worth debating before committing.
Inclusive vs. exclusive display. Most VAT/GST countries require tax-inclusive shelf/menu pricing; US-style sales tax is exclusive by convention (add at checkout).
tax_typealready exists per product, so this is less of an open question for us — but it means the default for new countries needs to be right out of the box (e.g. defaulting to exclusive for a new EU country would be a compliance bug, not just a UX one). (inclusive pricing overview)Open questions for contributors
tax_type: 'compound'that references a base tax's computed amount, or a separatesurchargestable keyed off tax config?tax_rateoffproductsentirely and onto atax_category(food / alcohol / electronics / exempt / …), with category→rate resolved per country/state at calc time?Related code:
tax.ts,countries.ts