Skip to content

Make tax engine universal — surcharges, per-category rates/discounts, more countries #143

Description

@itsbkm

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

  1. 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.

  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions