From e4478eeb095066c6568fe689b1cdfb0deda9ffdd Mon Sep 17 00:00:00 2001
From: carvalab
Date: Fri, 31 Jul 2026 15:37:49 -0300
Subject: [PATCH 1/4] feat(tax): add Argentina tax pack
(official-argentina@1.0.0)
Scope (approved before this draft):
- Merchant: IVA-registered restaurants (Responsable Inscripto),
domestic sales of standard supplies.
- Tax: one IVA rule at the general rate of 21%, applied across
every category (product, addon, packaging, delivery,
service_charge, unclassified).
- Default pricing is tax-inclusive to match Argentina's consumer-
price-display rule (Res. 4/2025); individual products can still
be set exclusive per-item for B2B use.
Source: Ley de Impuesto al Valor Agregado, texto ordenado en 1997
(Ley 23.349 t.o. Decreto 280/1997, Anexo I), Articulo 28 -- 'La
alicuota del impuesto sera del veintiuno por ciento (21%)'.
Continuously in force since BO 15/04/1997. Cross-references: ARCA
biblioteca cuadro legislativo Articulo 28; InfoLeg norma 42701.
Intentionally unsupported (do not add to this pack without a scope
bump): IIBB (provincial, registration-dependent); reduced 10.5%;
incremented 27% (utilities); 2.5% and 0%; Monotributo; withholding
and perception regimes; ARCA CAE fiscal-invoice authorization
(belongs to the capability-plugin seam #142).
Tests:
- tests/tax-engine.test.ts: ARS 100 exclusive -> 21 -> 121; ARS 121
inclusive -> net 100 / IVA 21; all charge kinds apply the rule;
unclassified never silently zero.
- tests/tax-pack-management.test.ts: installs AR alongside IN/TH
and confirms the 24-point activation checklist accepts it (only
check #6, signed artifact, fails pre-signing, same as IN/TH seeds).
- tests/integration-tax.test.ts: AR exclusive order (ARS 1000 ->
210 IVA -> 1210 total); 20% discount recompute (168/968);
inclusive product keeps IVA inside the displayed price and
persists behavior as inclusive; bill copies the order tax; every
breakdown asserted to contain no IIBB/Ingresos Brutos component.
Docs: docs/tax-packs.md gains a 'Country pack scopes' section
pinning the approved merchant scope, the IVA source, every excluded
area, and the upgrade path for provincial IIBB (only after FloCafe
models province + activity code + customer registration status).
---
docs/tax-packs.md | 19 +++++++
main/tax-packs/argentina.json | 50 ++++++++++++++++++
tests/integration-tax.test.ts | 84 +++++++++++++++++++++++++++++++
tests/tax-engine.test.ts | 65 ++++++++++++++++++++++++
tests/tax-pack-management.test.ts | 4 +-
5 files changed, 221 insertions(+), 1 deletion(-)
create mode 100644 main/tax-packs/argentina.json
diff --git a/docs/tax-packs.md b/docs/tax-packs.md
index bb544790..5a241b31 100644
--- a/docs/tax-packs.md
+++ b/docs/tax-packs.md
@@ -29,6 +29,25 @@ It must not embed scripts, call network endpoints, or introduce a second tax-cal
5. Add test vectors: extend `tests/tax-pack-management.test.ts` (activation validation) and, ideally, `tests/tax-engine.test.ts` / `tests/integration-tax.test.ts` with a scenario proving your rules produce the expected components, totals, and rounding for at least one representative order.
6. Run `npm run test:tax-engine` and the full `npm test` before opening the PR.
+## Country pack scopes
+
+This section records the merchant scope, legal source, and intentional exclusions for each official country pack. A pack's JSON only encodes what its scope covers — anything listed under "intentionally unsupported" is *not* a bug, *not* a placeholder for a future update, and *not* something to add to the same pack without a scope bump and a new version.
+
+### Argentina (`official-argentina`)
+
+- **Approved merchant scope:** IVA-registered restaurants operating as *Responsable Inscripto* under the general regime, on domestic sales of standard supplies only. Default pricing is tax-inclusive because Argentina consumer-price-display law (Secretariat of Industry and Commerce Resolution 4/2025) requires the displayed price to be the total and final amount paid by the consumer; individual products can still be marked exclusive per-item for B2B use.
+- **Tax covered:** one IVA rule at the general rate of **21%** applied across every category (`standard`, `packaging`, `delivery`, `service_charge`, `addon`, `unclassified`). Inclusive-of-IVA consumer display is the default; exclusive pricing can be selected per product, per add-on, or per configurable charge.
+- **Legal source:** Ley de Impuesto al Valor Agregado, texto ordenado en 1997 (Ley N° 23.349 t.o. **Decreto N° 280/1997**, Anexo I), **Artículo 28**: *“La alícuota del impuesto será del veintiuno por ciento (21 %).”* Continuously in force since publication in Boletín Oficial 15/04/1997. Cross-references: ARCA / AFIP *Biblioteca* cuadro legislativo de alícuotas de IVA; InfoLeg norma 42701; SAIJ.
+- **Intentional unsupported (do not add to this pack without a scope bump):**
+ - **IIBB (Ingresos Brutos).** Provincial — administered by each *Dirección General de Rentas* — with rates that vary by province, registered activity, and registration status (typical bands: commercial services up to ~4.5%, industry up to ~3%, others up to ~6%). FloCafe does not model province-level merchant activity or customer registration status, so IIBB cannot be calculated correctly from data already on a transaction. No IIBB component is emitted under any breakdown or snapshot. Adding IIBB to this pack without those inputs would silently under- or over-report.
+ - **Reduced-rate IVA (10.5%).** Applies to specific unprocessed goods (meat, fruit, vegetables, agricultural services, transport, housing construction, some medical services, Tierra del Fuego electronics). Requires per-product NCM/activity classification FloCafe does not model.
+ - **Incremented-rate IVA (27%).** Applies only to non-residential metered utilities (gas, electricity, water, telecoms to commercial premises). Out of scope for restaurant supplies.
+ - **2.5% super-reduced (printed newspapers/magazines)** and **0% (exports).** Not applicable to domestic restaurant sales.
+ - **Monotributo.** Simplified-regime merchants issue *Factura C* without itemized IVA and follow a separate monthly unified payment; the pack assumes *Responsable Inscripto*, which issues itemized *Factura A/B*.
+ - **Withholding and perception regimes** (RG 4240 IVA perception, etc.) and **fiscal-invoice authorization (CAE via ARCA)**: both require external authorizations and are part of the executable capability-plugin seam tracked in [#142](https://github.com/FreeOpenSourcePOS/FloCafe/issues/142), not a tax-pack concern.
+- **Upgrade path for provincial IIBB.** A future pack version `official-argentina@1.x.0` (or a successor pack) may add IIBB *only after* FloCafe models the inputs it needs: the merchant's registered province (ARCA jurisdiction), the IIBB activity code(s), and the customer's tax-status (Responsable Inscripto vs Monotributo vs Exento). Until those inputs exist end-to-end, a pack cannot compute IIBB correctly and must not claim to.
+- **Sources checked:** ARCA *Biblioteca* cuadro legislativo (alícuotas Art. 28); InfoLeg norma 42701 with notas Infoleg (Ley 27.702 extension to 2027, Decreto 567/2019 0% canasta); SAIJ texto actualizado; Secretaría de Industria y Comercio Resolución 4/2025 (price display); Avalara Argentina VAT compliance guide.
+
## Where packs live, how they get signed, and how they're published
Pack source and release artifacts have separate homes. Reviewable source, signing code, and the release workflow stay in this repository; signed tax-pack artifacts (and future capability-plugin artifacts) are published to [`FreeOpenSourcePOS/FloCafe-Plugins`](https://github.com/FreeOpenSourcePOS/FloCafe-Plugins), keeping FloCafe's Releases tab for application installers.
diff --git a/main/tax-packs/argentina.json b/main/tax-packs/argentina.json
new file mode 100644
index 00000000..26892fd7
--- /dev/null
+++ b/main/tax-packs/argentina.json
@@ -0,0 +1,50 @@
+{
+ "schemaVersion": 1,
+ "id": "official-argentina",
+ "publisher": "FreeOpenSourcePOS",
+ "version": "1.0.0",
+ "country": "AR",
+ "jurisdiction": "*",
+ "currency": "ARS",
+ "effectiveFrom": "2026-01-01",
+ "publishedAt": "2026-01-01",
+ "minFloVersion": "2.4.0",
+ "taxPoint": "finalized_at",
+ "inclusivePricingDefault": true,
+ "registrationNumberLabel": "CUIT",
+ "categories": [
+ { "id": "standard", "label": "Standard", "ruleIds": ["iva"] },
+ { "id": "packaging", "label": "Packaging", "ruleIds": ["iva"] },
+ { "id": "delivery", "label": "Delivery", "ruleIds": ["iva"] },
+ { "id": "service_charge", "label": "Service charge", "ruleIds": ["iva"] },
+ { "id": "addon", "label": "Add-on", "ruleIds": ["iva"] },
+ { "id": "unclassified", "label": "Unclassified", "ruleIds": ["iva"] }
+ ],
+ "defaultCategories": {
+ "product": "standard",
+ "packaging": "packaging",
+ "delivery": "delivery",
+ "service_charge": "service_charge",
+ "addon": "addon"
+ },
+ "unclassifiedCategoryId": "unclassified",
+ "rules": [
+ {
+ "id": "iva",
+ "label": "IVA",
+ "type": "percent",
+ "categoryIds": ["standard", "packaging", "delivery", "service_charge", "addon", "unclassified"],
+ "rate": "21"
+ }
+ ],
+ "taxRounding": {
+ "scope": "line",
+ "method": "half_up",
+ "decimalPlaces": 2,
+ "remainderAllocation": "largest_remainder"
+ },
+ "payableRounding": {
+ "increment": "0.01",
+ "method": "half_up"
+ }
+}
diff --git a/tests/integration-tax.test.ts b/tests/integration-tax.test.ts
index 57836c4d..d518591f 100644
--- a/tests/integration-tax.test.ts
+++ b/tests/integration-tax.test.ts
@@ -33,6 +33,7 @@ const { billRoutes } = require('../main/routes/bills');
const { registerRoutes } = require('../main/routes/index');
const indiaTaxPack = require('../main/tax-packs/in.json');
const thailandTaxPack = require('../main/tax-packs/th.json');
+const argentinaTaxPack = require('../main/tax-packs/argentina.json');
async function main() {
console.log('Integration Test: Tax Correctness');
@@ -46,6 +47,7 @@ async function main() {
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('state_code', '27', ?)").run(now());
installAndActivateTestTaxPack(db, indiaTaxPack);
installAndActivateTestTaxPack(db, thailandTaxPack);
+ installAndActivateTestTaxPack(db, argentinaTaxPack);
// Seed data
const { authHeader } = seedOwnerUser(db);
@@ -509,6 +511,88 @@ async function main() {
.run(activeThailandVersion.pack_json, activeThailandVersion.id);
db.prepare("UPDATE settings SET value = 'IN' WHERE key = 'country'").run();
+ // ── Step 11: Argentina standard 21% IVA — exclusive + inclusive + discount — no IIBB ──
+ console.log('\n11. Argentina standard 21% IVA produces no IIBB component and discounts scale correctly');
+ db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'AR', ?)").run(now());
+ db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_type', 'restaurant', ?)").run(now());
+ db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('state_code', '02', ?)").run(now());
+
+ seedProduct(db, 'prod-tax-ar-1', 'cat-tax', 'Argentina Cafe', 1000, {
+ tax_category_id: 'standard',
+ tax_behavior: 'exclusive',
+ });
+ seedProduct(db, 'prod-tax-ar-2', 'cat-tax', 'Argentina Cafe Inclusive', 1210, {
+ tax_category_id: 'standard',
+ tax_behavior: 'inclusive',
+ });
+
+ const arExclusiveRes = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'prod-tax-ar-1', quantity: 1 }],
+ },
+ headers: authHeader,
+ });
+ assertEqual(arExclusiveRes.status, 201, 'Argentina exclusive order created');
+ assertEqual(arExclusiveRes.data.order.subtotal, 1000, 'AR exclusive subtotal = ARS 1000');
+ assertEqual(arExclusiveRes.data.order.tax_amount, 210, 'AR exclusive tax = ARS 210 (21% of ARS 1000)');
+ assertEqual(arExclusiveRes.data.order.total, 1210, 'AR exclusive total = ARS 1210 (1000 + 210)');
+ const arRawBreakdown = arExclusiveRes.data.order.tax_breakdown;
+ const arParsed = typeof arRawBreakdown === 'string' ? JSON.parse(arRawBreakdown) : arRawBreakdown;
+ const arEntries = Array.isArray(arParsed[0]) ? arParsed.flat() : arParsed;
+ const arIva = arEntries.find((b: any) => b.title === 'IVA');
+ assert(!!arIva, 'Argentina breakdown contains an IVA entry');
+ assert(
+ arEntries.every((b: any) => !/IIBB|ingresos/i.test(b.title)),
+ 'Argentina breakdown must NOT contain an IIBB/Ingresos Brutos component (provincial, out of scope)',
+ );
+ assertEqual(arIva.rate, 21, 'Argentina IVA component rate is 21');
+
+ // Discount recompute: 20% off ARS 1000 = ARS 800 base; 21% = ARS 168 tax; total ARS 968.
+ const arDiscountRes = await api(baseUrl, `/api/orders/${arExclusiveRes.data.order.id}/discount`, {
+ method: 'PATCH',
+ body: { discount_type: 'percentage', discount_value: 20 },
+ headers: authHeader,
+ });
+ assertEqual(arDiscountRes.status, 200, 'AR discount applied');
+ assertEqual(arDiscountRes.data.order.discount_amount, 200, 'AR discount = ARS 200 (20% of 1000)');
+ assertEqual(arDiscountRes.data.order.tax_amount, 168, 'AR discounted tax = ARS 168 (21% of 800)');
+ assertEqual(arDiscountRes.data.order.total, 968, 'AR discounted total = ARS 968');
+
+ const arInclusiveRes = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'prod-tax-ar-2', quantity: 1 }],
+ },
+ headers: authHeader,
+ });
+ assertEqual(arInclusiveRes.status, 201, 'AR inclusive order created');
+ assertEqual(arInclusiveRes.data.order.tax_amount, 210, 'AR inclusive IVA = ARS 210 (tax inside the displayed price, net of 1000)');
+ assertEqual(arInclusiveRes.data.order.total, 1210, 'AR inclusive total = ARS 1210 (IVA not added again)');
+ assert(!!arInclusiveRes.data.order.items[0].tax_snapshot, 'AR inclusive item carries a tax_snapshot');
+ assertEqual(arInclusiveRes.data.order.items[0].tax_type, 'inclusive', 'AR inclusive behavior persists as inclusive');
+
+ const arBillRes = await api(baseUrl, '/api/bills/generate', {
+ method: 'POST',
+ body: { order_id: arExclusiveRes.data.order.id },
+ headers: authHeader,
+ });
+ assertEqual(arBillRes.status, 201, 'AR bill generated from exclusive order');
+ assertEqual(arBillRes.data.bill.tax_amount, 168, 'AR bill copies discounted IVA = ARS 168');
+ assertEqual(arBillRes.data.bill.total, 968, 'AR bill total matches discounted order');
+ const arBillParsed = typeof arBillRes.data.bill.tax_breakdown === 'string'
+ ? JSON.parse(arBillRes.data.bill.tax_breakdown)
+ : arBillRes.data.bill.tax_breakdown;
+ const arBillEntries = Array.isArray(arBillParsed[0]) ? arBillParsed.flat() : arBillParsed;
+ assert(
+ arBillEntries.every((b: any) => !/IIBB|ingresos/i.test(b.title)),
+ 'AR bill breakdown contains no IIBB component',
+ );
+
+ db.prepare("UPDATE settings SET value = 'IN' WHERE key = 'country'").run();
+
} finally {
server.close();
closeDatabase();
diff --git a/tests/tax-engine.test.ts b/tests/tax-engine.test.ts
index 894c0ca2..103ec954 100644
--- a/tests/tax-engine.test.ts
+++ b/tests/tax-engine.test.ts
@@ -11,9 +11,11 @@ import type {
} from '../main/tax-packs/types';
import indiaPackData from '../main/tax-packs/in.json';
import thailandPackData from '../main/tax-packs/th.json';
+import argentinaPackData from '../main/tax-packs/argentina.json';
const indiaPack = indiaPackData as CountryPack;
const thailandPack = thailandPackData as CountryPack;
+const argentinaPack = argentinaPackData as CountryPack;
function packWith(
rules: TaxRule[],
@@ -368,6 +370,69 @@ test('catalog India and Thailand packs reproduce current fixed behavior as data'
assert.equal(thailandPack.rules[0].rate, '7');
});
+test('catalog Argentina pack reproduces standard 21% IVA as data', () => {
+ // Scope: Responsable Inscripto restaurant merchants, standard supplies only.
+ // No IIBB, no monotributo, no 10.5%/27%, no fiscal-invoice authorization.
+ const exclusive = calculate(argentinaPack, [
+ line({ unitPrice: '100', productCategoryId: 'standard', taxBehavior: 'exclusive' }),
+ ], { businessType: 'restaurant' });
+ assert.deepEqual(
+ exclusive.lines[0].components.map((component) => ({
+ title: component.label,
+ rate: Number(component.rate),
+ amount: Number(component.amount),
+ })),
+ [{ title: 'IVA', rate: 21, amount: 21 }],
+ );
+ assert.equal(exclusive.lines[0].taxableBase, '100.00');
+ assert.equal(exclusive.lines[0].taxAmount, '21.00');
+ assert.equal(exclusive.payableTotal, '121', 'ARS 100 exclusive -> IVA 21 -> total 121');
+
+ const inclusive = calculate(argentinaPack, [
+ line({ unitPrice: '121', productCategoryId: 'standard', taxBehavior: 'inclusive' }),
+ ], { businessType: 'restaurant' });
+ assert.equal(inclusive.lines[0].taxableBase, '100.00', 'ARS 121 inclusive nets to 100');
+ assert.equal(inclusive.lines[0].taxAmount, '21.00', 'ARS 121 inclusive carries 21 IVA');
+ assert.equal(inclusive.payableTotal, '121', 'inclusive payable total keeps the gross price');
+
+ // Category coverage: product / addon / packaging / delivery / service_charge
+ // all resolve to a category with the IVA rule attached (no silent zero).
+ const categoriesByKind = {
+ product: 'standard',
+ addon: 'addon',
+ packaging: 'packaging',
+ delivery: 'delivery',
+ service_charge: 'service_charge',
+ } as const;
+ for (const [kind, categoryId] of Object.entries(categoriesByKind)) {
+ const result = calculate(argentinaPack, [
+ line({ kind: kind as any, productCategoryId: categoryId, taxBehavior: 'exclusive' }),
+ ], { businessType: 'restaurant' });
+ assert.equal(
+ result.lines[0].components.length,
+ 1,
+ `${kind}/${categoryId} applies the IVA rule (no silent zero-tax path)`,
+ );
+ assert.equal(result.lines[0].components[0].ruleId, 'iva');
+ assert.equal(result.lines[0].taxAmount, '21.00');
+ }
+
+ // Unclassified must still tax at 21% (never silently zero), proving the
+ // pack would not underreport a misclassified item on a Responsable Inscripto.
+ const unclassified = calculate(argentinaPack, [
+ line({ unitPrice: '100', productCategoryId: 'unclassified', taxBehavior: 'exclusive' }),
+ ], { businessType: 'restaurant' });
+ assert.deepEqual(
+ unclassified.lines[0].components.map((component) => [component.label, component.amount]),
+ [['IVA', '21.00']],
+ );
+ const unclassifiedCat = argentinaPack.categories.find((category) => category.id === 'unclassified')!;
+ assert.ok(unclassifiedCat.ruleIds.length > 0, 'Argentina unclassified category carries the IVA rule');
+
+ // Discount scaling on Argentina inclusive pricing is exercised end-to-end
+ // in tests/integration-tax.test.ts against the live order/bill recompute path.
+});
+
test('unclassified products are taxed at the standard rate, never silently zero', () => {
const india = calculate(indiaPack, [
line({ unitPrice: '1000', productCategoryId: 'unclassified', taxBehavior: 'exclusive' }),
diff --git a/tests/tax-pack-management.test.ts b/tests/tax-pack-management.test.ts
index a49c6133..bcb9ba76 100644
--- a/tests/tax-pack-management.test.ts
+++ b/tests/tax-pack-management.test.ts
@@ -48,6 +48,7 @@ const {
} = require('../main/tax-packs/catalog');
const indiaPackDefinition = require('../main/tax-packs/in.json');
const thailandPackDefinition = require('../main/tax-packs/th.json');
+const argentinaPackDefinition = require('../main/tax-packs/argentina.json');
async function main() {
console.log('Tax Pack Management Integration Tests');
@@ -76,6 +77,7 @@ async function main() {
installAndActivateTestTaxPack(db, indiaPackDefinition);
installAndActivateTestTaxPack(db, thailandPackDefinition);
+ installAndActivateTestTaxPack(db, argentinaPackDefinition);
const listRes = await api(baseUrl, '/api/tax-packs', { headers: manager.authHeader });
const india = listRes.data.packs.find((pack: any) => pack.id === 'official-india');
assert(!!india, 'India pack is listed');
@@ -95,7 +97,7 @@ async function main() {
'6',
'the test-seeded India source only lacks its release signature',
);
- for (const packId of ['official-thailand', 'local-generic']) {
+ for (const packId of ['official-thailand', 'official-argentina', 'local-generic']) {
const packDetail = await api(baseUrl, `/api/tax-packs/${packId}`, { headers: manager.authHeader });
assertEqual(packDetail.status, 200, `${packId} details are readable`);
assertEqual(
From 054e0647a72a7e2574ecd430e0e5c79b5d539f69 Mon Sep 17 00:00:00 2001
From: carvalab
Date: Fri, 31 Jul 2026 15:37:57 -0300
Subject: [PATCH 2/4] fix(e2e): register Thailand pack in e2e-server fixture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pre-existing CI regression, included here to keep this PR's CI green.
Logically belongs to commit 3a75876 ('publish country packs
separately'), not to the Argentina pack.
3a75876 moved India and Thailand from BUNDLED_COUNTRY_PACKS to
catalog-only delivery. tests/e2e-server.cjs seeds country='TH'
without simulating the post-first-run owner action of installing
Thailand from Settings -> Tax Configuration. The active pack for
country=TH then falls back to the zero-rules generic pack, and
checkout throws 'no tax rules apply to category standard for
business type restaurant' (400). The Playwright spec at
prepaid-payment-reconciliation.spec.ts:13/55 times out waiting for
'Tax ฿4.20'.
Same regression silently failed the most recent two main CI runs
(30614681034, 30619469114) before this PR opened.
Fix: inline the same install-tax-pack SQL helper that the unit and
integration tests already use (tests/helpers/test-setup.ts:
installAndActivateTestTaxPack), since e2e-server.cjs runs from
compiled dist/ and cannot import the .ts helper. Only Thailand is
installed -- the only country-specific pack exercised by e2e specs.
India stays catalog-only as 3a75876 intended. No production code
touched.
---
tests/e2e-server.cjs | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
diff --git a/tests/e2e-server.cjs b/tests/e2e-server.cjs
index b436ee44..a88bff51 100644
--- a/tests/e2e-server.cjs
+++ b/tests/e2e-server.cjs
@@ -16,6 +16,7 @@ Module._load = function (request, parent, isMain) {
};
const bcrypt = require('bcryptjs');
+const crypto = require('crypto');
const { initDatabase, getDatabase, closeDatabase, now } = require('../dist/db');
const { startServer, stopServer } = require('../dist/server');
const { startKdsServer, stopKdsServer } = require('../dist/kds-server');
@@ -26,6 +27,79 @@ function seedUser(id, email, role) {
).run(id, `E2E ${role}`, email, bcrypt.hashSync('E2ePass123!', 10), role, now(), now());
}
+// After commit 3a75876 ("publish country packs separately"), only the generic
+// pack auto-installs on first-run; country packs come from the signed catalog
+// after an owner opt-in. The e2e fixture seeds country=TH but never goes
+// through that owner action, so it has no active TH pack and the checkout
+// throws "no tax rules apply". Mirror the production owner-install action by
+// registering the TH pack directly. This is the same SQL helper that the
+// unit/integration tests use, just inlined here because e2e-server runs from
+// compiled dist/ and cannot import the .ts test helpers.
+function installTaxPackFixture(pack) {
+ const db = getDatabase();
+ const installedAt = now();
+ const versionId = `${pack.id}@${pack.version}`;
+ const packJson = JSON.stringify(pack);
+ const digest = crypto.createHash('sha256').update(packJson).digest('hex');
+
+ db.transaction(() => {
+ db.prepare(`
+ INSERT INTO country_packs (
+ id, publisher, country, jurisdiction, active_version_id, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ publisher = excluded.publisher,
+ country = excluded.country,
+ jurisdiction = excluded.jurisdiction,
+ active_version_id = excluded.active_version_id,
+ status = 'active',
+ updated_at = excluded.updated_at
+ `).run(pack.id, pack.publisher, pack.country, pack.jurisdiction, versionId, installedAt, installedAt);
+
+ db.prepare(`
+ INSERT OR REPLACE INTO country_pack_versions (
+ id, pack_id, version, schema_version, manifest_json, pack_json, digest, signature,
+ effective_from, effective_to, min_flo_version, published_at, status, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 'active', ?)
+ `).run(
+ versionId, pack.id, pack.version, pack.schemaVersion,
+ JSON.stringify({
+ id: pack.id, publisher: pack.publisher, country: pack.country,
+ jurisdiction: pack.jurisdiction, version: pack.version, publishedAt: pack.publishedAt,
+ }),
+ packJson, digest,
+ pack.effectiveFrom, pack.effectiveTo || null, pack.minFloVersion, pack.publishedAt,
+ installedAt,
+ );
+
+ const insertCategory = db.prepare(`
+ INSERT OR REPLACE INTO tax_categories (
+ id, pack_version_id, category_id, label, default_behavior, definition_json, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+ for (const category of pack.categories) {
+ insertCategory.run(
+ `${versionId}:category:${category.id}`, versionId, category.id, category.label,
+ category.defaultBehavior || null, JSON.stringify(category), installedAt,
+ );
+ }
+
+ const insertRule = db.prepare(`
+ INSERT OR REPLACE INTO tax_rules (
+ id, pack_version_id, rule_id, label, calculation_type, rate, amount,
+ applies_per, base_rule_ids, definition_json, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ for (const rule of pack.rules) {
+ insertRule.run(
+ `${versionId}:rule:${rule.id}`, versionId, rule.id, rule.label, rule.type,
+ rule.rate || null, rule.amount || null, rule.appliesPer || null,
+ JSON.stringify(rule.baseRuleIds || []), JSON.stringify(rule), installedAt,
+ );
+ }
+ })();
+}
+
function seedPosFixture() {
const db = getDatabase();
const createdAt = now();
@@ -82,6 +156,7 @@ function stop(exitCode = 0) {
initDatabase();
seedUser('e2e-manager', 'manager@flo.local', 'manager');
seedPosFixture();
+ installTaxPackFixture(require('../main/tax-packs/th.json'));
await startServer();
await startKdsServer();
console.log('[E2E] Main and KDS servers ready');
From 8f4c9ff49fa8459783ac3c283919761670fa171b Mon Sep 17 00:00:00 2001
From: carvalab
Date: Fri, 31 Jul 2026 15:58:50 -0300
Subject: [PATCH 3/4] docs(tax): reframe Argentina scope around consumidor
final
User feedback: the 21% IVA is what the end consumer pays at the
counter, which is roughly 99% of restaurant POS transactions. The
previous scope text led with 'IVA-registered restaurants operating
as Responsable Inscripto', which framed the pack around the
merchant's tax-status instead of the consumer-facing calculation the
pack actually models.
The pack's calculation is identical for end-consumer sales (Factura
B, IVA embedded) and B2B sales between Responsables Inscriptos
(Factura A, IVA itemized) -- same 21% number, different invoice
presentation. That makes Factura A in-scope too, just framed as a
side note about invoicing rather than as a scope restriction.
The Monotributo exclusion is reworded to clarify that a Monotributo
merchant would need a separate zero-IVA pack variant because its
invoicing and registration paths are structurally different, not
because the math is incompatible.
No rate change, no rule change, no test change. Pack data, pack
activation, all tests still green. Docs only.
---
docs/tax-packs.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/tax-packs.md b/docs/tax-packs.md
index 5a241b31..262aaf70 100644
--- a/docs/tax-packs.md
+++ b/docs/tax-packs.md
@@ -35,7 +35,7 @@ This section records the merchant scope, legal source, and intentional exclusion
### Argentina (`official-argentina`)
-- **Approved merchant scope:** IVA-registered restaurants operating as *Responsable Inscripto* under the general regime, on domestic sales of standard supplies only. Default pricing is tax-inclusive because Argentina consumer-price-display law (Secretariat of Industry and Commerce Resolution 4/2025) requires the displayed price to be the total and final amount paid by the consumer; individual products can still be marked exclusive per-item for B2B use.
+- **Approved merchant scope:** AR merchants' domestic sales of standard supplies to *consumidor final* (end consumer) at the general 21% IVA rate — the everyday restaurant POS case, which covers roughly 99% of transactions. The pack computes the 21% that the consumer-facing price already includes (Factura B-style, IVA embedded in the displayed total). The same 21% rate also applies to B2B Factura A transactions between *Responsables Inscriptos* (same number, different invoice presentation), so this pack is also usable there — invoicing presentation is an upstream concern, not a tax-calculation one. Default pricing stays tax-inclusive because Argentina consumer-price-display law (Secretariat of Industry and Commerce Resolution 4/2025) requires the displayed price to be the total and final amount paid by the consumer; individual products can still be marked exclusive per-item.
- **Tax covered:** one IVA rule at the general rate of **21%** applied across every category (`standard`, `packaging`, `delivery`, `service_charge`, `addon`, `unclassified`). Inclusive-of-IVA consumer display is the default; exclusive pricing can be selected per product, per add-on, or per configurable charge.
- **Legal source:** Ley de Impuesto al Valor Agregado, texto ordenado en 1997 (Ley N° 23.349 t.o. **Decreto N° 280/1997**, Anexo I), **Artículo 28**: *“La alícuota del impuesto será del veintiuno por ciento (21 %).”* Continuously in force since publication in Boletín Oficial 15/04/1997. Cross-references: ARCA / AFIP *Biblioteca* cuadro legislativo de alícuotas de IVA; InfoLeg norma 42701; SAIJ.
- **Intentional unsupported (do not add to this pack without a scope bump):**
@@ -43,7 +43,7 @@ This section records the merchant scope, legal source, and intentional exclusion
- **Reduced-rate IVA (10.5%).** Applies to specific unprocessed goods (meat, fruit, vegetables, agricultural services, transport, housing construction, some medical services, Tierra del Fuego electronics). Requires per-product NCM/activity classification FloCafe does not model.
- **Incremented-rate IVA (27%).** Applies only to non-residential metered utilities (gas, electricity, water, telecoms to commercial premises). Out of scope for restaurant supplies.
- **2.5% super-reduced (printed newspapers/magazines)** and **0% (exports).** Not applicable to domestic restaurant sales.
- - **Monotributo.** Simplified-regime merchants issue *Factura C* without itemized IVA and follow a separate monthly unified payment; the pack assumes *Responsable Inscripto*, which issues itemized *Factura A/B*.
+ - **Monotributo.** Simplified-regime merchants issue *Factura C* without itemized IVA and follow a separate monthly unified payment. This pack assumes the standard IVA-registered regime (Responsable Inscripto issuing Factura A/B with IVA at 21%); a Monotributo merchant would need a separate zero-IVA pack variant because the invoicing and tax-registration paths are structurally different.
- **Withholding and perception regimes** (RG 4240 IVA perception, etc.) and **fiscal-invoice authorization (CAE via ARCA)**: both require external authorizations and are part of the executable capability-plugin seam tracked in [#142](https://github.com/FreeOpenSourcePOS/FloCafe/issues/142), not a tax-pack concern.
- **Upgrade path for provincial IIBB.** A future pack version `official-argentina@1.x.0` (or a successor pack) may add IIBB *only after* FloCafe models the inputs it needs: the merchant's registered province (ARCA jurisdiction), the IIBB activity code(s), and the customer's tax-status (Responsable Inscripto vs Monotributo vs Exento). Until those inputs exist end-to-end, a pack cannot compute IIBB correctly and must not claim to.
- **Sources checked:** ARCA *Biblioteca* cuadro legislativo (alícuotas Art. 28); InfoLeg norma 42701 with notas Infoleg (Ley 27.702 extension to 2027, Decreto 567/2019 0% canasta); SAIJ texto actualizado; Secretaría de Industria y Comercio Resolución 4/2025 (price display); Avalara Argentina VAT compliance guide.
From e970c0c72d2181566244d83c84f181fa9a784268 Mon Sep 17 00:00:00 2001
From: carvalab
Date: Fri, 31 Jul 2026 19:29:34 -0300
Subject: [PATCH 4/4] feat(tax): store-wide defaults for products with custom
rate
Adds a single-bar 'Default for all' control in the fiscal settings
panel that lets the owner pick a category for every kind (product,
add-on, packaging, delivery, service charge) and apply it in one
click. The control also accepts a typed rate (e.g. 10.5) and creates
a merchant category with that rate when the value doesn't match an
existing one. Per-kind dropdowns below stay for exceptions.
Backend: `POST /api/tax-packs/merchant-rate` (owner-only, validated
0 < rate <= 100) upserts a `merchant:` category + rule for the
active version and writes the 5 store-wide overrides in one
transaction. Idempotent. `getActiveCountryPack` merges merchant rows
into the returned pack so the engine resolves them. Activate copies
merchant categories and rules across versions; the install-time
override-conflict check ignores `merchant:%` aliases.
---
.../settings/TaxConfigurationPanel.tsx | 567 +++++++++++++-----
frontend/src/lib/i18n/en.json | 128 ++++
frontend/src/lib/i18n/es.json | 128 ++++
frontend/src/lib/i18n/pt.json | 128 ++++
main/routes/auth.ts | 18 +-
main/routes/tax-packs.ts | 114 +++-
main/services/tax.ts | 106 +++-
tests/tax-pack-management.test.ts | 190 ++++++
8 files changed, 1181 insertions(+), 198 deletions(-)
diff --git a/frontend/src/components/settings/TaxConfigurationPanel.tsx b/frontend/src/components/settings/TaxConfigurationPanel.tsx
index 8deb2a27..6ebba0ad 100644
--- a/frontend/src/components/settings/TaxConfigurationPanel.tsx
+++ b/frontend/src/components/settings/TaxConfigurationPanel.tsx
@@ -19,6 +19,7 @@ import {
import toast from 'react-hot-toast';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
+import { useI18n } from '@/hooks/useI18n';
type PackSummary = {
id: string;
@@ -128,23 +129,25 @@ type CatalogEntry = {
digest: string;
};
-const ENTITY_LABELS: Record = {
- product: 'Product',
- addon: 'Add-on',
- packaging: 'Packaging charge',
- delivery: 'Delivery charge',
- service_charge: 'Service charge',
+const ENTITY_LABEL_KEYS: Record = {
+ product: 'settings.taxEntityProduct',
+ addon: 'settings.taxEntityAddon',
+ packaging: 'settings.taxEntityPackaging',
+ delivery: 'settings.taxEntityDelivery',
+ service_charge: 'settings.taxEntityServiceCharge',
};
-const CHARGE_TYPES: OverrideEntityType[] = ['packaging', 'delivery', 'service_charge'];
-
-const ACTION_LABELS: Record = {
- install_bundled_pack: 'Bundled pack installed',
- install_downloaded_pack: 'Downloaded pack installed',
- activate_pack: 'Pack activated',
- rollback_pack: 'Pack rolled back',
- create_override: 'Override added',
- update_override: 'Override edited',
- reset_override: 'Override removed',
+const STORE_DEFAULT_KINDS: OverrideEntityType[] = [
+ 'product', 'addon', 'packaging', 'delivery', 'service_charge',
+];
+
+const ACTION_LABEL_KEYS: Record = {
+ install_bundled_pack: 'settings.taxAuditBundledInstalled',
+ install_downloaded_pack: 'settings.taxAuditDownloadedInstalled',
+ activate_pack: 'settings.taxAuditPackActivated',
+ rollback_pack: 'settings.taxAuditPackRolledBack',
+ create_override: 'settings.taxAuditOverrideAdded',
+ update_override: 'settings.taxAuditOverrideEdited',
+ reset_override: 'settings.taxAuditOverrideRemoved',
};
function apiMessage(error: unknown, fallback: string): string {
@@ -161,27 +164,8 @@ function categoryIdOf(override: TaxOverride): string {
return override.value?.categoryId || '';
}
-function auditDescription(row: AuditRow): string {
- const details = row.details || {};
- if (row.action === 'install_bundled_pack') return `Version ${String(details.version || '')} from the application bundle`;
- if (row.action === 'install_downloaded_pack') return `Version ${String(details.version || '')} verified and installed from GitHub Releases`;
- if (row.action === 'create_override') {
- return `${String(details.entityType || 'target')} ${String(details.entityId || 'store-wide')} → ${String(details.categoryId || '')}`;
- }
- if (row.action === 'update_override') {
- const before = (details.before || {}) as Record;
- const after = (details.after || {}) as Record;
- return `${String(after.entityType || before.entityType || 'target')} ${String(after.entityId || before.entityId || 'store-wide')}: ${String(before.categoryId || '')} → ${String(after.categoryId || '')}`;
- }
- if (row.action === 'reset_override') {
- return `${String(details.entityType || 'target')} ${String(details.entityId || 'store-wide')} reset to official behavior`;
- }
- if (row.action === 'activate_pack') return `Previous version: ${String(details.previousVersionId || 'none')}`;
- if (row.action === 'rollback_pack') return `Rolled back from ${String(details.previousVersionId || 'unknown')}`;
- return '';
-}
-
export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
+ const { t } = useI18n();
const [packs, setPacks] = useState([]);
const [storeCountry, setStoreCountry] = useState('');
const [selectedPackId, setSelectedPackId] = useState('');
@@ -201,9 +185,64 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogChecked, setCatalogChecked] = useState(false);
const [catalogEntries, setCatalogEntries] = useState([]);
+ const [applyAllSelect, setApplyAllSelect] = useState('');
+ const [customRate, setCustomRate] = useState('');
+ const [customRateError, setCustomRateError] = useState(null);
const [enablingTaxes, setEnablingTaxes] = useState(false);
const [countryPackUnavailable, setCountryPackUnavailable] = useState(false);
+ const entityLabel = useCallback(
+ (type: OverrideEntityType) => t(ENTITY_LABEL_KEYS[type]),
+ [t],
+ );
+
+ const auditDescription = useCallback(
+ (row: AuditRow): string => {
+ const details = row.details || {};
+ if (row.action === 'install_bundled_pack') {
+ return t('settings.taxAuditDescBundledInstalled', { version: String(details.version || '') });
+ }
+ if (row.action === 'install_downloaded_pack') {
+ return t('settings.taxAuditDescDownloadedInstalled', { version: String(details.version || '') });
+ }
+ if (row.action === 'create_override') {
+ return t('settings.taxAuditDescOverrideAdded', {
+ entityType: String(details.entityType || 'target'),
+ entityId: String(details.entityId || 'store-wide'),
+ categoryId: String(details.categoryId || ''),
+ });
+ }
+ if (row.action === 'update_override') {
+ const before = (details.before || {}) as Record;
+ const after = (details.after || {}) as Record;
+ return t('settings.taxAuditDescOverrideEdited', {
+ entityType: String(after.entityType || before.entityType || 'target'),
+ entityId: String(after.entityId || before.entityId || 'store-wide'),
+ before: String(before.categoryId || ''),
+ after: String(after.categoryId || ''),
+ });
+ }
+ if (row.action === 'reset_override') {
+ return t('settings.taxAuditDescOverrideRemoved', {
+ entityType: String(details.entityType || 'target'),
+ entityId: String(details.entityId || 'store-wide'),
+ });
+ }
+ if (row.action === 'activate_pack') {
+ return t('settings.taxAuditDescActivated', {
+ previousVersionId: String(details.previousVersionId || 'none'),
+ });
+ }
+ if (row.action === 'rollback_pack') {
+ return t('settings.taxAuditDescRolledBack', {
+ previousVersionId: String(details.previousVersionId || 'unknown'),
+ });
+ }
+ return '';
+ },
+ [t],
+ );
+
const loadList = useCallback(async () => {
const response = await api.get('/tax-packs');
const nextPacks = response.data.packs as PackSummary[];
@@ -240,11 +279,11 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
...(selectedPackId ? [loadDetail(selectedPackId)] : []),
]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not load tax configuration'));
+ toast.error(apiMessage(error, t('settings.taxLoadFailed')));
} finally {
setLoading(false);
}
- }, [loadAudit, loadDetail, loadList, selectedPackId]);
+ }, [loadAudit, loadDetail, loadList, selectedPackId, t]);
useEffect(() => {
let cancelled = false;
@@ -260,13 +299,13 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
setAudit(auditResponse.data.audit);
})
.catch((error) => {
- if (!cancelled) toast.error(apiMessage(error, 'Could not load tax configuration'));
+ if (!cancelled) toast.error(apiMessage(error, t('settings.taxLoadFailed')));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
- }, []);
+ }, [t]);
useEffect(() => {
if (!selectedPackId) return;
@@ -281,13 +320,13 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
setCalculation(null);
})
.catch((error) => {
- if (!cancelled) toast.error(apiMessage(error, 'Could not load tax pack details'));
+ if (!cancelled) toast.error(apiMessage(error, t('settings.taxLoadDetailFailed')));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
- }, [selectedPackId]);
+ }, [selectedPackId, t]);
const selectedPack = packs.find((pack) => pack.id === selectedPackId);
const activeCountryPack = packs.find(
@@ -304,6 +343,65 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
[detail?.categories],
);
+ // Map category → display label that includes its rate (e.g. "Standard · 21%").
+ // Map category → its rate string. Built once per render so the dispatcher
+ // doesn't re-scan `detail.rules` for every category it considers.
+ const categoryRateById: Map = (() => {
+ const map = new Map();
+ if (!detail?.categories || !detail?.rules) return map;
+ for (const category of detail.categories) {
+ const categoryEntry = detail.categories.find((entry) => entry.category_id === category.category_id);
+ const ruleIds = Array.isArray(categoryEntry?.definition?.ruleIds) ? categoryEntry!.definition!.ruleIds : [];
+ const rule = detail.rules.find((candidate) => (
+ ruleIds.includes(candidate.rule_id)
+ || (Array.isArray(candidate.definition?.categoryIds) && candidate.definition!.categoryIds!.includes(category.category_id))
+ ));
+ const rate = rule?.rate;
+ map.set(category.category_id, rate !== null && rate !== undefined ? rate : '');
+ }
+ return map;
+ })();
+
+ // Map category → display label that includes its rate (e.g. "Standard · 21%").
+ const categoryRateLabel: Map = (() => {
+ const map = new Map();
+ if (!detail?.categories) return map;
+ for (const category of detail.categories) {
+ const rate = categoryRateById.get(category.category_id);
+ map.set(category.category_id, rate ? `${category.label} · ${rate}%` : category.label);
+ }
+ return map;
+ })();
+
+ // Pack default category for products (Argentina = "standard"). Used to
+ // pre-select the "Default for all" dropdown so the user sees what value
+ // will be applied without having to pick.
+ const packDefaultCategoryId = (
+ (detail?.active_version?.definition as unknown as { defaultCategories?: Record })?.defaultCategories
+ )?.product
+ ?? '';
+
+ // Look up the rate (as a string, e.g. "21" or "10.5") for a given pack
+ // category. Used by the typed-rate input and the dispatcher.
+ function getCategoryRate(categoryId: string): string {
+ return categoryRateById.get(categoryId) ?? '';
+ }
+
+ // Update the input to mirror the selected category's rate when the dropdown
+ // changes, so the user sees what value the picked category actually has.
+ function handleApplyAllSelectChange(nextCategoryId: string) {
+ setApplyAllSelect(nextCategoryId);
+ const rate = getCategoryRate(nextCategoryId);
+ setCustomRate(rate);
+ setCustomRateError(null);
+ }
+
+ // The input shows user input when present; otherwise it reflects the rate
+ // of the currently selected category (or the pack default). Computed so the
+ // user always sees the value that will be applied.
+ const displayedRate = customRate
+ || getCategoryRate(applyAllSelect || packDefaultCategoryId);
+
function resetOverrideForm() {
setEditingOverrideId(null);
setEntityType('product');
@@ -321,7 +419,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
async function saveOverride() {
if (!isOwner) return;
if (!categoryId || (needsEntity && !entityId)) {
- toast.error('Choose both a target and a tax category');
+ toast.error(t('settings.taxChooseTargetAndCategory'));
return;
}
setSaving(true);
@@ -333,45 +431,48 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
};
if (editingOverrideId) {
await api.put(`/tax-packs/overrides/${editingOverrideId}`, payload);
- toast.success('Tax override updated');
+ toast.success(t('settings.taxOverrideUpdated'));
} else {
await api.post('/tax-packs/overrides', payload);
- toast.success('Tax override added');
+ toast.success(t('settings.taxOverrideAdded'));
}
resetOverrideForm();
await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not save tax override'));
+ toast.error(apiMessage(error, t('settings.taxOverrideSaveFailed')));
} finally {
setSaving(false);
}
}
async function removeOverride(override: TaxOverride) {
- if (!isOwner || !window.confirm(`Remove the override for ${override.entity_name || ENTITY_LABELS[override.entity_type]}?`)) return;
+ if (!isOwner) return;
+ const label = override.entity_name || entityLabel(override.entity_type);
+ if (!window.confirm(t('settings.taxRemoveOverrideConfirm', { entity: label }))) return;
setSaving(true);
try {
await api.delete(`/tax-packs/overrides/${override.id}`);
- toast.success('Tax override removed; official pack behavior restored');
+ toast.success(t('settings.taxOverrideRemoved'));
if (editingOverrideId === override.id) resetOverrideForm();
await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not remove tax override'));
+ toast.error(apiMessage(error, t('settings.taxOverrideRemoveFailed')));
} finally {
setSaving(false);
}
}
async function setChargeCategory(entityType: OverrideEntityType, nextCategoryId: string) {
- if (!isOwner || !selectedPack?.active_for_store || !CHARGE_TYPES.includes(entityType)) return;
+ if (!isOwner || !selectedPack?.active_for_store || !STORE_DEFAULT_KINDS.includes(entityType)) return;
const current = detail?.overrides.find(
(override) => override.entity_type === entityType && override.entity_id === null,
);
+ const label = entityLabel(entityType);
setSaving(true);
try {
if (!nextCategoryId) {
if (current) await api.delete(`/tax-packs/overrides/${current.id}`);
- toast.success(`${ENTITY_LABELS[entityType]} restored to legacy behavior`);
+ toast.success(t('settings.taxStoreWideDefaultRestored', { entity: label }));
} else {
const payload = {
entity_type: entityType,
@@ -383,39 +484,120 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
} else {
await api.post('/tax-packs/overrides', payload);
}
- toast.success(`${ENTITY_LABELS[entityType]} tax category saved`);
+ toast.success(t('settings.taxStoreWideDefaultSaved', { entity: label }));
}
await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not save the charge tax category'));
+ toast.error(apiMessage(error, t('settings.taxStoreWideDefaultSaveFailed')));
} finally {
setSaving(false);
}
}
+ async function applyToAll() {
+ if (!isOwner || !selectedPack?.active_for_store || !applyAllSelect) return;
+ const categoryLabel = detail?.categories.find((category) => category.category_id === applyAllSelect)?.label;
+ setSaving(true);
+ try {
+ for (const kind of STORE_DEFAULT_KINDS) {
+ const current = detail?.overrides.find(
+ (override) => override.entity_type === kind && override.entity_id === null,
+ );
+ const payload = { entity_type: kind, entity_id: null, category_id: applyAllSelect };
+ if (current) {
+ await api.put(`/tax-packs/overrides/${current.id}`, payload);
+ } else {
+ await api.post('/tax-packs/overrides', payload);
+ }
+ }
+ setApplyAllSelect('');
+ await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
+ toast.success(t('settings.taxApplyToAllSuccess', { category: categoryLabel || applyAllSelect }));
+ } catch (error) {
+ toast.error(apiMessage(error, t('settings.taxStoreWideDefaultSaveFailed')));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function applyCustomRate() {
+ const rateNumber = Number(customRate);
+ if (!customRate || !Number.isFinite(rateNumber) || rateNumber <= 0 || rateNumber > 100) {
+ setCustomRateError(t('settings.taxCustomRateInvalid'));
+ return;
+ }
+ setSaving(true);
+ try {
+ const response = await api.post('/tax-packs/merchant-rate', { rate: customRate });
+ setCustomRate('');
+ setCustomRateError(null);
+ setApplyAllSelect(response.data?.categoryId || '');
+ await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
+ toast.success(t('settings.taxApplyToAllSuccess', { category: `Custom ${response.data?.rate}%` }));
+ } catch (error: unknown) {
+ const errorMessage = (error as { response?: { data?: { error?: string } } })?.response?.data?.error
+ || t('settings.taxStoreWideDefaultSaveFailed');
+ setCustomRateError(errorMessage);
+ toast.error(errorMessage);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function applySelectOrCustom() {
+ const value = (customRate || displayedRate).trim();
+ const selectedCategoryId = applyAllSelect || packDefaultCategoryId;
+ const selectedRate = getCategoryRate(selectedCategoryId);
+
+ // Input matches the selected category's rate → use the selected category.
+ if (value && value === selectedRate) {
+ return applyToAll();
+ }
+
+ // Input matches a different existing category's rate → switch to that
+ // category and apply. Avoids creating a duplicate merchant category when
+ // the typed value already exists in the pack.
+ if (value && detail?.categories) {
+ const match = detail.categories.find((category) => getCategoryRate(category.category_id) === value);
+ if (match) {
+ setApplyAllSelect(match.category_id);
+ return applyToAll();
+ }
+ }
+
+ // Input is a new rate → create a merchant category from the typed value.
+ if (value) {
+ return applyCustomRate();
+ }
+
+ // No input → apply the selected category (or pack default).
+ return applyToAll();
+ }
+
async function activateVersion(versionId: string) {
if (!isOwner || !selectedPackId) return;
setSaving(true);
try {
await api.post(`/tax-packs/${selectedPackId}/versions/${versionId}/activate`);
- toast.success('Installed tax pack version activated');
+ toast.success(t('settings.taxVersionActivated'));
await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not activate tax pack version'));
+ toast.error(apiMessage(error, t('settings.taxVersionActivateFailed')));
} finally {
setSaving(false);
}
}
async function rollback() {
- if (!isOwner || !selectedPackId || !window.confirm('Roll back to the previous installed version?')) return;
+ if (!isOwner || !selectedPackId) return;
+ if (!window.confirm(t('settings.taxRollbackConfirm'))) return;
setSaving(true);
try {
await api.post(`/tax-packs/${selectedPackId}/rollback`);
- toast.success('Tax pack rolled back');
+ toast.success(t('settings.taxRolledBack'));
await Promise.all([loadDetail(selectedPackId), loadList(), loadAudit()]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not roll back tax pack'));
+ toast.error(apiMessage(error, t('settings.taxRollbackFailed')));
} finally {
setSaving(false);
}
@@ -428,11 +610,11 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
const available = response.data.available as CatalogEntry[];
setCatalogEntries(available);
setCatalogChecked(true);
- if (available.length === 0) toast.success('Installed tax packs are up to date');
+ if (available.length === 0) toast.success(t('settings.taxUpToDate'));
} catch (error) {
setCatalogEntries([]);
setCatalogChecked(true);
- toast.error(apiMessage(error, 'Could not check for tax pack updates'));
+ toast.error(apiMessage(error, t('settings.taxCheckUpdatesFailed')));
} finally {
setCatalogLoading(false);
}
@@ -446,14 +628,14 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
pack_id: entry.id,
version: entry.version,
});
- toast.success(`Tax pack ${entry.id} v${entry.version} verified and installed`);
+ toast.success(t('settings.taxPackInstalled', { id: entry.id, version: entry.version }));
setCatalogEntries((current) => current.filter(
(candidate) => candidate.id !== entry.id || candidate.version !== entry.version,
));
setSelectedPackId(entry.id);
await Promise.all([loadList(), loadAudit(), loadDetail(entry.id)]);
} catch (error) {
- toast.error(apiMessage(error, 'Could not install tax pack update'));
+ toast.error(apiMessage(error, t('settings.taxPackInstallFailed')));
} finally {
setSaving(false);
}
@@ -498,16 +680,16 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
}
if (!packId || !versionId || !version) {
- throw new Error('The selected tax pack has no installable version');
+ throw new Error(t('settings.taxPackNoInstallable'));
}
await api.post(
`/tax-packs/${encodeURIComponent(packId)}/versions/${encodeURIComponent(versionId)}/activate`,
);
setSelectedPackId(packId);
await Promise.all([loadList(), loadAudit(), loadDetail(packId)]);
- toast.success(`Taxes enabled with ${packId} v${version}`);
+ toast.success(t('settings.taxEnabledWith', { id: packId, version }));
} catch (error) {
- toast.error(apiMessage(error, 'Could not enable taxes for this country'));
+ toast.error(apiMessage(error, t('settings.taxEnableTaxesFailed')));
} finally {
setEnablingTaxes(false);
}
@@ -515,12 +697,12 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
async function calculate() {
if (!selectedPack?.active_for_store) {
- toast.error('Select the active store-country pack to run a checkout calculation');
+ toast.error(t('settings.taxNeedActivePack'));
return;
}
const amountNum = Number(testAmount);
if (!testCategoryId || !testAmount || isNaN(amountNum) || amountNum <= 0) {
- toast.error('Please enter a valid positive test calculation amount');
+ toast.error(t('settings.taxInvalidAmount'));
return;
}
try {
@@ -532,22 +714,20 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
setCalculation(response.data.calculation);
} catch (error) {
setCalculation(null);
- toast.error(apiMessage(error, 'Could not calculate tax'));
+ toast.error(apiMessage(error, t('settings.taxCalculateFailed')));
}
}
if (loading && !detail) {
- return Loading tax configuration…
;
+ return {t('settings.taxLoading')}
;
}
return (
-
Tax configuration
-
- Review installed country packs, apply merchant overrides, and test calculations.
-
+
{t('settings.taxConfiguration')}
+
{t('settings.taxConfigSubtitle')}
- {catalogLoading ? 'Checking…' : 'Check for updates'}
+ {catalogLoading ? t('settings.taxChecking') : t('settings.taxCheckForUpdates')}
- Refresh
+ {t('settings.taxRefresh')}
@@ -574,7 +754,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
{!isOwner && (
- Managers can review packs, overrides, audit history, and run test calculations. Only owners can make changes.
+ {t('settings.taxManagerNotice')}
)}
@@ -582,26 +762,24 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Country taxes are not enabled
+
{t('settings.taxNotEnabledTitle')}
- FloCafe is using the generic no-tax profile. An owner can download, verify, and
- activate an official pack for {storeCountry} when internet access is available.
+ {t('settings.taxNotEnabledBody', { country: storeCountry })}
void enableCountryTaxes()}
disabled={!isOwner || enablingTaxes}
- title={!isOwner ? 'Only owners can enable taxes' : undefined}
+ title={!isOwner ? t('settings.taxOwnerOnlyEnable') : undefined}
className="shrink-0"
>
- {enablingTaxes ? 'Enabling…' : 'Enable taxes'}
+ {enablingTaxes ? t('settings.taxEnabling') : t('settings.taxEnableTaxes')}
{countryPackUnavailable && (
- No official tax pack is currently available for {storeCountry}. Keep taxes disabled,
- or use the generic/manual configuration below.
+ {t('settings.taxCountryPackUnavailable', { country: storeCountry })}
)}
@@ -613,10 +791,8 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Available tax packs
-
- Downloads are verified with the built-in FloCafe signing key before installation.
-
+
{t('settings.taxAvailablePacks')}
+
{t('settings.taxAvailablePacksHint')}
@@ -632,10 +808,10 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
{entry.id} · v{entry.version}
- {entry.country === '*' ? 'Generic' : entry.country}
+ {entry.country === '*' ? t('settings.taxGeneric') : entry.country}
{' · '}{entry.publisher}
- {' · '}Published {entry.publishedAt}
- {' · '}Requires FloCafe {entry.minFloVersion}+
+ {' · '}{t('settings.taxPublished', { date: entry.publishedAt })}
+ {' · '}{t('settings.taxRequiresFlo', { version: entry.minFloVersion })}
void installCatalogPack(entry)}
- title={!isOwner ? 'Only owners can install tax packs' : undefined}
+ title={!isOwner ? t('settings.taxOwnerOnlyInstall') : undefined}
>
- Verify and install
+ {t('settings.taxVerifyAndInstall')}
))}
) : (
- No newer tax pack versions are available.
+ {t('settings.taxNoUpdates')}
)}
)}
@@ -660,7 +836,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Installed country packs
+ {t('settings.taxInstalledCountryPacks')}
{packs.length > 1 && (
{packs.map((pack) => (
- {pack.country === '*' ? 'Generic' : pack.country} · {pack.publisher}
- {pack.active_for_store ? ' (active)' : ''}
+ {pack.country === '*' ? t('settings.taxGeneric') : pack.country} · {pack.publisher}
+ {pack.active_for_store ? ` (${t('common.active')})` : ''}
))}
@@ -683,14 +859,14 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
{detail.active_version ? (
<>
-
-
-
-
+
+
+
+
-
Effective {detail.active_version.effective_from}
-
Published {detail.active_version.published_at}
+
{t('settings.taxEffective', { date: detail.active_version.effective_from })}
+
{t('settings.taxPublished', { date: detail.active_version.published_at })}
{detail.active_version.definition.currency}
{detail.active_version.validation.valid ? : }
{detail.active_version.validation.valid
- ? `${detail.active_version.validation.checks.filter((c) => c.passed).length} of ${detail.active_version.validation.checks.length} activation checks passed`
- : 'Activation checks failed'}
+ ? t('settings.taxActivationChecksPassed', {
+ passed: detail.active_version.validation.checks.filter((c) => c.passed).length,
+ total: detail.active_version.validation.checks.length,
+ })
+ : t('settings.taxActivationChecksFailed')}
@@ -715,21 +894,19 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
)}
>
) : (
-
- This pack has no active version yet — activate an installed version below.
-
+
{t('settings.taxNoActiveVersion')}
)}
-
Installed versions
+
{t('settings.taxInstalledVersions')}
void rollback()}
- title={detail.versions.length < 2 ? 'No previous installed version is available' : undefined}
+ title={detail.versions.length < 2 ? t('settings.taxNoPreviousVersion') : undefined}
>
- Roll back
+ {t('settings.taxRollback')}
@@ -742,7 +919,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
{version.status}
{active ? (
- Active
+ {t('common.active')}
) : (
void activateVersion(version.id)}
>
- Activate installed version
+ {t('settings.taxActivateVersion')}
)}
@@ -760,18 +937,18 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
>
) : (
-
No active installed pack is available.
+
{t('settings.taxNoActivePack')}
)}
-
Test calculation
+ {t('settings.taxTestCalculation')}
- Uses the active pack and the same tax engine as checkout. It does not save a transaction.
+ {t('settings.taxTestCalculationHint')}
{!selectedPack?.active_for_store && (
- This installed pack is not active for the current store. Select the active pack to test it.
+ {t('settings.taxNotActivePackTest')}
)}
setTestCategoryId(event.target.value)} className="rounded-md border border-gray-200 px-3 py-2 text-sm disabled:bg-gray-100">
@@ -781,24 +958,24 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
value={testAmount}
onChange={(event) => setTestAmount(event.target.value)}
inputMode="decimal"
- placeholder="Amount"
+ placeholder={t('settings.taxTaxableBase')}
disabled={!selectedPack?.active_for_store}
className="rounded-md border border-gray-200 px-3 py-2 text-sm disabled:bg-gray-100"
/>
setTestBehavior(event.target.value)} className="rounded-md border border-gray-200 px-3 py-2 text-sm disabled:bg-gray-100">
- Country default
- Tax exclusive
- Tax inclusive
- Exempt
+ {t('settings.taxCountryDefault')}
+ {t('settings.taxExclusive')}
+ {t('settings.taxInclusive')}
+ {t('settings.taxExempt')}
- void calculate()}>Calculate
+ void calculate()}>{t('settings.taxCalculate')}
{calculation && (
-
-
-
+
+
+
{calculation.lines[0]?.components.length > 0 && (
@@ -817,31 +994,93 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Charge tax categories
+ {t('settings.taxStoreWideDefaults')}
-
- Choose the category used for each order-level charge. Unconfigured charges keep the legacy behavior and remain untaxed.
-
+ {t('settings.taxStoreWideDefaultsHint')}
{!selectedPack?.active_for_store && (
- Select the active store-country pack to change charge categories.
+ {t('settings.taxNeedActivePackDefaults')}
)}
-
- {CHARGE_TYPES.map((chargeType) => {
+
+
+
+ {t('settings.taxApplyToAll')}
+ {t('settings.taxApplyToAllHint')}
+
+
handleApplyAllSelectChange(event.target.value)}
+ disabled={!isOwner || saving || !selectedPack?.active_for_store}
+ className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm disabled:bg-gray-100 sm:flex-1"
+ >
+ —
+ {detail?.categories
+ .filter((category) => Boolean(getCategoryRate(category.category_id)))
+ .map((category) => (
+
+ {categoryRateLabel.get(category.category_id) || category.label}
+
+ ))}
+
+
+ {
+ const value = event.target.value;
+ setCustomRate(value);
+ setCustomRateError(null);
+ // If the typed value matches an existing category's rate,
+ // switch the dropdown to that category so the user sees
+ // the canonical pick.
+ if (value && detail?.categories) {
+ const match = detail.categories.find((category) => getCategoryRate(category.category_id) === value);
+ if (match) setApplyAllSelect(match.category_id);
+ }
+ }}
+ placeholder="—"
+ disabled={!isOwner || saving || !selectedPack?.active_for_store}
+ aria-label={t('settings.taxCustomRate')}
+ className="w-24 rounded-md border border-gray-200 bg-white px-3 py-2 text-sm disabled:bg-gray-100"
+ />
+ %
+
+
void applySelectOrCustom()}
+ className="shrink-0"
+ >
+ {t('settings.taxApplyToAllButton')}
+
+
+ {customRateError && (
+ {customRateError}
+ )}
+
+
+
+
+ {STORE_DEFAULT_KINDS.map((kind) => {
const configured = detail?.overrides.find(
- (override) => override.entity_type === chargeType && override.entity_id === null,
+ (override) => override.entity_type === kind && override.entity_id === null,
);
return (
-
- {ENTITY_LABELS[chargeType]}
+
+ {entityLabel(kind)}
void setChargeCategory(chargeType, event.target.value)}
+ onChange={(event) => void setChargeCategory(kind, event.target.value)}
disabled={!isOwner || saving || !selectedPack?.active_for_store}
className="mt-2 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm disabled:bg-gray-100"
>
- Not configured · legacy behavior
+ {t('settings.taxNotConfigured')}
{detail?.categories.map((category) => (
- {category.label}
+
+ {categoryRateLabel.get(category.category_id) || category.label}
+
))}
@@ -853,11 +1092,9 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Merchant overrides
+ {t('settings.taxMerchantOverrides')}
-
- Overrides take priority over product and category assignments, but transaction exemptions still win.
-
+ {t('settings.taxMerchantOverridesHint')}
{isOwner && (
@@ -870,24 +1107,24 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm"
>
{(['product', 'addon'] as OverrideEntityType[]).map((value) => (
-
{ENTITY_LABELS[value]}
+
{entityLabel(value)}
))}
{needsEntity ? (
setEntityId(event.target.value)} className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
- Choose {ENTITY_LABELS[entityType].toLowerCase()}
+ {t('settings.taxChooseTarget', { entity: entityLabel(entityType).toLowerCase() })}
{targetOptions.map((target) => {target.name} )}
) : (
-
Store-wide charge
+
{t('settings.taxStoreWideCharge')}
)}
setCategoryId(event.target.value)} className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
{detail?.categories.map((category) => {category.label} )}
- {editingOverrideId &&
Cancel }
+ {editingOverrideId &&
{t('common.cancel')} }
void saveOverride()}>
- {editingOverrideId ? 'Save override' : 'Add override'}
+ {editingOverrideId ? t('settings.taxSaveOverride') : t('settings.taxAddOverride')}
@@ -896,46 +1133,46 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
- Target Category Updated Actions
+ {t('settings.taxTarget')} {t('settings.taxCategory')} {t('settings.taxUpdated')} {t('settings.taxActions')}
{detail?.overrides.map((override) => (
- {ENTITY_LABELS[override.entity_type]} {override.entity_name || 'Store-wide'}
+ {entityLabel(override.entity_type)} {override.entity_name || t('settings.taxStoreWide')}
{categoriesById.get(categoryIdOf(override)) || categoryIdOf(override)}
{dateTime(override.updated_at)}{override.created_by_name ? ` · ${override.created_by_name}` : ''}
{isOwner ? (
- {!CHARGE_TYPES.includes(override.entity_type) && (
- editOverride(override)}>Edit
+ {override.entity_id !== null && (
+ editOverride(override)}>{t('common.edit')}
)}
- void removeOverride(override)}>Remove
+ void removeOverride(override)}>{t('common.remove')}
- ) : Read only }
+ ) : {t('settings.taxReadOnly')} }
))}
- {!detail?.overrides.length && No merchant overrides. Official pack behavior is in use. }
+ {!detail?.overrides.length && {t('settings.taxNoOverrides')} }
- Pack reference
- Read-only categories and rules from the active installed JSON pack.
+ {t('settings.taxPackReference')}
+ {t('settings.taxPackReferenceHint')}
- Category Default behavior Rules
+ {t('settings.taxCategory')} {t('settings.taxDefaultBehavior')} {t('settings.taxRules')}
{detail?.categories.map((category) => (
{category.label} {category.category_id}
- {category.default_behavior || 'Pack default'}
- {category.definition.ruleIds?.join(', ') || 'None'}
+ {category.default_behavior || t('settings.taxPackDefault')}
+ {category.definition.ruleIds?.join(', ') || t('settings.taxNone')}
))}
@@ -944,7 +1181,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
- Rule Type Value Scope Depends on
+ {t('settings.taxRule')} {t('settings.taxType')} {t('settings.taxValue')} {t('settings.taxScope')} {t('settings.taxDependsOn')}
{detail?.rules.map((rule) => (
@@ -953,7 +1190,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
{rule.calculation_type}
{rule.rate !== null ? `${rule.rate}%` : rule.amount}
{rule.applies_per}
- {rule.base_rule_ids.join(', ') || 'None'}
+ {rule.base_rule_ids.join(', ') || t('settings.taxNone')}
))}
@@ -964,20 +1201,20 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
-
Audit history
+ {t('settings.taxAuditHistory')}
{audit.map((row) => (
-
{ACTION_LABELS[row.action] || row.action}
+
{t(ACTION_LABEL_KEYS[row.action] || row.action)}
{auditDescription(row) &&
{auditDescription(row)}
}
-
{row.actor_name || (row.actor_user_id ? 'Unknown user' : 'System')} · {dateTime(row.created_at)}
+
{row.actor_name || (row.actor_user_id ? t('settings.taxUnknownUser') : t('settings.taxSystem'))} · {dateTime(row.created_at)}
))}
- {!audit.length &&
No tax configuration changes recorded.
}
+ {!audit.length &&
{t('settings.taxNoAudit')}
}
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 6ca1c5ef..a2c95afc 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -756,6 +756,134 @@
"settings.status": "Status",
"settings.storeDetails": "Store Details",
"settings.taxConfiguration": "Tax configuration",
+ "settings.taxLoading": "Loading tax configuration…",
+ "settings.taxConfigSubtitle": "Review installed country packs, apply merchant overrides, and test calculations.",
+ "settings.taxCheckForUpdates": "Check for updates",
+ "settings.taxChecking": "Checking…",
+ "settings.taxRefresh": "Refresh",
+ "settings.taxManagerNotice": "Managers can review packs, overrides, audit history, and run test calculations. Only owners can make changes.",
+ "settings.taxNotEnabledTitle": "Country taxes are not enabled",
+ "settings.taxNotEnabledBody": "FloCafe is using the generic no-tax profile. An owner can download, verify, and activate an official pack for {country} when internet access is available.",
+ "settings.taxOwnerOnlyEnable": "Only owners can enable taxes",
+ "settings.taxEnabling": "Enabling…",
+ "settings.taxEnableTaxes": "Enable taxes",
+ "settings.taxCountryPackUnavailable": "No official tax pack is currently available for {country}. Keep taxes disabled, or use the generic/manual configuration below.",
+ "settings.taxAvailablePacks": "Available tax packs",
+ "settings.taxAvailablePacksHint": "Downloads are verified with the built-in FloCafe signing key before installation.",
+ "settings.taxGeneric": "Generic",
+ "settings.taxPublished": "Published {date}",
+ "settings.taxRequiresFlo": "Requires FloCafe {version}+",
+ "settings.taxVerifyAndInstall": "Verify and install",
+ "settings.taxOwnerOnlyInstall": "Only owners can install tax packs",
+ "settings.taxNoUpdates": "No newer tax pack versions are available.",
+ "settings.taxInstalledCountryPacks": "Installed country packs",
+ "settings.taxStoreCountry": "Store country",
+ "settings.taxJurisdiction": "Jurisdiction",
+ "settings.taxActiveVersion": "Active version",
+ "settings.taxTrustStatus": "Trust status",
+ "settings.taxEffective": "Effective {date}",
+ "settings.taxActivationChecksPassed": "{passed} of {total} activation checks passed",
+ "settings.taxActivationChecksFailed": "Activation checks failed",
+ "settings.taxNoActiveVersion": "This pack has no active version yet — activate an installed version below.",
+ "settings.taxInstalledVersions": "Installed versions",
+ "settings.taxNoPreviousVersion": "No previous installed version is available",
+ "settings.taxRollback": "Roll back",
+ "settings.taxActivateVersion": "Activate installed version",
+ "settings.taxNoActivePack": "No active installed pack is available.",
+ "settings.taxTestCalculation": "Test calculation",
+ "settings.taxTestCalculationHint": "Uses the active pack and the same tax engine as checkout. It does not save a transaction.",
+ "settings.taxNotActivePackTest": "This installed pack is not active for the current store. Select the active pack to test it.",
+ "settings.taxCountryDefault": "Country default",
+ "settings.taxExclusive": "Tax exclusive",
+ "settings.taxInclusive": "Tax inclusive",
+ "settings.taxExempt": "Exempt",
+ "settings.taxCalculate": "Calculate",
+ "settings.taxInvalidAmount": "Please enter a valid positive test calculation amount",
+ "settings.taxNeedActivePack": "Select the active store-country pack to run a checkout calculation",
+ "settings.taxTaxableBase": "Taxable base",
+ "settings.taxPayableTotal": "Payable total",
+ "settings.taxStoreWideDefaults": "Store-wide tax defaults",
+ "settings.taxStoreWideDefaultsHint": "Default category for products and add-ons without their own category, and for order-level charges. Unconfigured entries fall back to the pack default.",
+ "settings.taxNeedActivePackDefaults": "Select the active store-country pack to change store-wide defaults.",
+ "settings.taxApplyToAll": "Default for all",
+ "settings.taxApplyToAllHint": "Pick one category and apply it to every kind (products, add-ons, and charges) at once.",
+ "settings.taxApplyToAllButton": "Apply to all",
+ "settings.taxApplyToAllSuccess": "Applied {category} to all store-wide defaults",
+ "settings.taxCustomRate": "Custom rate",
+ "settings.taxCustomRateInvalid": "Enter a rate between 0 and 100",
+ "settings.taxNotConfigured": "Not configured · legacy behavior",
+ "settings.taxMerchantOverrides": "Merchant overrides",
+ "settings.taxMerchantOverridesHint": "Overrides take priority over product and category assignments, but transaction exemptions still win.",
+ "settings.taxChooseTarget": "Choose {entity}",
+ "settings.taxStoreWideCharge": "Store-wide charge",
+ "settings.taxSaveOverride": "Save override",
+ "settings.taxAddOverride": "Add override",
+ "settings.taxTarget": "Target",
+ "settings.taxCategory": "Category",
+ "settings.taxUpdated": "Updated",
+ "settings.taxActions": "Actions",
+ "settings.taxStoreWide": "Store-wide",
+ "settings.taxReadOnly": "Read only",
+ "settings.taxNoOverrides": "No merchant overrides. Official pack behavior is in use.",
+ "settings.taxPackReference": "Pack reference",
+ "settings.taxPackReferenceHint": "Read-only categories and rules from the active installed JSON pack.",
+ "settings.taxDefaultBehavior": "Default behavior",
+ "settings.taxRules": "Rules",
+ "settings.taxNone": "None",
+ "settings.taxPackDefault": "Pack default",
+ "settings.taxRule": "Rule",
+ "settings.taxType": "Type",
+ "settings.taxValue": "Value",
+ "settings.taxScope": "Scope",
+ "settings.taxDependsOn": "Depends on",
+ "settings.taxAuditHistory": "Audit history",
+ "settings.taxUnknownUser": "Unknown user",
+ "settings.taxSystem": "System",
+ "settings.taxNoAudit": "No tax configuration changes recorded.",
+ "settings.taxLoadFailed": "Could not load tax configuration",
+ "settings.taxLoadDetailFailed": "Could not load tax pack details",
+ "settings.taxChooseTargetAndCategory": "Choose both a target and a tax category",
+ "settings.taxOverrideUpdated": "Tax override updated",
+ "settings.taxOverrideAdded": "Tax override added",
+ "settings.taxOverrideSaveFailed": "Could not save tax override",
+ "settings.taxRemoveOverrideConfirm": "Remove the override for {entity}?",
+ "settings.taxOverrideRemoved": "Tax override removed; official pack behavior restored",
+ "settings.taxOverrideRemoveFailed": "Could not remove tax override",
+ "settings.taxStoreWideDefaultRestored": "{entity} default cleared",
+ "settings.taxStoreWideDefaultSaved": "{entity} default saved",
+ "settings.taxStoreWideDefaultSaveFailed": "Could not save the store-wide default",
+ "settings.taxVersionActivated": "Installed tax pack version activated",
+ "settings.taxVersionActivateFailed": "Could not activate tax pack version",
+ "settings.taxRollbackConfirm": "Roll back to the previous installed version?",
+ "settings.taxRolledBack": "Tax pack rolled back",
+ "settings.taxRollbackFailed": "Could not roll back tax pack",
+ "settings.taxUpToDate": "Installed tax packs are up to date",
+ "settings.taxCheckUpdatesFailed": "Could not check for tax pack updates",
+ "settings.taxPackInstalled": "Tax pack {id} v{version} verified and installed",
+ "settings.taxPackInstallFailed": "Could not install tax pack update",
+ "settings.taxPackNoInstallable": "The selected tax pack has no installable version",
+ "settings.taxEnabledWith": "Taxes enabled with {id} v{version}",
+ "settings.taxEnableTaxesFailed": "Could not enable taxes for this country",
+ "settings.taxCalculateFailed": "Could not calculate tax",
+ "settings.taxEntityProduct": "Product",
+ "settings.taxEntityAddon": "Add-on",
+ "settings.taxEntityPackaging": "Packaging charge",
+ "settings.taxEntityDelivery": "Delivery charge",
+ "settings.taxEntityServiceCharge": "Service charge",
+ "settings.taxAuditBundledInstalled": "Bundled pack installed",
+ "settings.taxAuditDownloadedInstalled": "Downloaded pack installed",
+ "settings.taxAuditPackActivated": "Pack activated",
+ "settings.taxAuditPackRolledBack": "Pack rolled back",
+ "settings.taxAuditOverrideAdded": "Override added",
+ "settings.taxAuditOverrideEdited": "Override edited",
+ "settings.taxAuditOverrideRemoved": "Override removed",
+ "settings.taxAuditDescBundledInstalled": "Version {version} from the application bundle",
+ "settings.taxAuditDescDownloadedInstalled": "Version {version} verified and installed from GitHub Releases",
+ "settings.taxAuditDescOverrideAdded": "{entityType} {entityId} → {categoryId}",
+ "settings.taxAuditDescOverrideEdited": "{entityType} {entityId}: {before} → {after}",
+ "settings.taxAuditDescOverrideRemoved": "{entityType} {entityId} reset to official behavior",
+ "settings.taxAuditDescActivated": "Previous version: {previousVersionId}",
+ "settings.taxAuditDescRolledBack": "Rolled back from {previousVersionId}",
"settings.orderNumberFormat": "Order Number Format",
"settings.orderNumberPrefix": "Prefix",
"settings.orderNumberPreview": "Preview",
diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json
index b5d8be9a..dc6f20d0 100644
--- a/frontend/src/lib/i18n/es.json
+++ b/frontend/src/lib/i18n/es.json
@@ -756,6 +756,134 @@
"settings.status": "Estado",
"settings.storeDetails": "Datos del Negocio",
"settings.taxConfiguration": "Configuración fiscal",
+ "settings.taxLoading": "Cargando configuración fiscal…",
+ "settings.taxConfigSubtitle": "Revisá los paquetes de país instalados, aplicá overrides del comerciante y probá cálculos.",
+ "settings.taxCheckForUpdates": "Buscar actualizaciones",
+ "settings.taxChecking": "Buscando…",
+ "settings.taxRefresh": "Actualizar",
+ "settings.taxManagerNotice": "Los gerentes pueden revisar paquetes, overrides, historial de auditoría y ejecutar cálculos de prueba. Solo los dueños pueden hacer cambios.",
+ "settings.taxNotEnabledTitle": "Los impuestos del país no están habilitados",
+ "settings.taxNotEnabledBody": "FloCafe está usando el perfil genérico sin impuestos. Un dueño puede descargar, verificar y activar un paquete oficial para {country} cuando haya acceso a internet.",
+ "settings.taxOwnerOnlyEnable": "Solo los dueños pueden habilitar los impuestos",
+ "settings.taxEnabling": "Habilitando…",
+ "settings.taxEnableTaxes": "Habilitar impuestos",
+ "settings.taxCountryPackUnavailable": "No hay un paquete fiscal oficial disponible para {country}. Mantené los impuestos deshabilitados o usá la configuración genérica/manual.",
+ "settings.taxAvailablePacks": "Paquetes fiscales disponibles",
+ "settings.taxAvailablePacksHint": "Las descargas se verifican con la clave de firma integrada de FloCafe antes de instalarse.",
+ "settings.taxGeneric": "Genérico",
+ "settings.taxPublished": "Publicado el {date}",
+ "settings.taxRequiresFlo": "Requiere FloCafe {version}+",
+ "settings.taxVerifyAndInstall": "Verificar e instalar",
+ "settings.taxOwnerOnlyInstall": "Solo los dueños pueden instalar paquetes fiscales",
+ "settings.taxNoUpdates": "No hay versiones más recientes de paquetes fiscales disponibles.",
+ "settings.taxInstalledCountryPacks": "Paquetes de país instalados",
+ "settings.taxStoreCountry": "País del local",
+ "settings.taxJurisdiction": "Jurisdicción",
+ "settings.taxActiveVersion": "Versión activa",
+ "settings.taxTrustStatus": "Estado de confianza",
+ "settings.taxEffective": "Vigente desde {date}",
+ "settings.taxActivationChecksPassed": "{passed} de {total} comprobaciones de activación aprobadas",
+ "settings.taxActivationChecksFailed": "Fallaron las comprobaciones de activación",
+ "settings.taxNoActiveVersion": "Este paquete aún no tiene una versión activa: activá una versión instalada debajo.",
+ "settings.taxInstalledVersions": "Versiones instaladas",
+ "settings.taxNoPreviousVersion": "No hay una versión instalada previa disponible",
+ "settings.taxRollback": "Revertir",
+ "settings.taxActivateVersion": "Activar versión instalada",
+ "settings.taxNoActivePack": "No hay un paquete instalado activo disponible.",
+ "settings.taxTestCalculation": "Cálculo de prueba",
+ "settings.taxTestCalculationHint": "Usa el paquete activo y el mismo motor fiscal que el checkout. No guarda una transacción.",
+ "settings.taxNotActivePackTest": "Este paquete instalado no está activo para el local actual. Seleccioná el paquete activo para probarlo.",
+ "settings.taxCountryDefault": "Predeterminado del país",
+ "settings.taxExclusive": "Impuesto exclusivo",
+ "settings.taxInclusive": "Impuesto incluido",
+ "settings.taxExempt": "Exento",
+ "settings.taxCalculate": "Calcular",
+ "settings.taxInvalidAmount": "Ingresá un importe positivo válido para el cálculo de prueba",
+ "settings.taxNeedActivePack": "Seleccioná el paquete del país activo para ejecutar un cálculo de checkout",
+ "settings.taxTaxableBase": "Base imponible",
+ "settings.taxPayableTotal": "Total a pagar",
+ "settings.taxStoreWideDefaults": "Predeterminados fiscales",
+ "settings.taxStoreWideDefaultsHint": "Categoría predeterminada para productos y adicionales sin categoría propia, y para cargos a nivel de pedido. Los valores no configurados vuelven al predeterminado del paquete.",
+ "settings.taxNeedActivePackDefaults": "Seleccioná el paquete del país activo para cambiar los predeterminados.",
+ "settings.taxApplyToAll": "Predeterminado para todo",
+ "settings.taxApplyToAllHint": "Elegí una categoría y aplicala a todos los tipos (productos, adicionales y cargos) de una vez.",
+ "settings.taxApplyToAllButton": "Aplicar a todo",
+ "settings.taxApplyToAllSuccess": "Se aplicó {category} a todos los predeterminados",
+ "settings.taxCustomRate": "Tasa personalizada",
+ "settings.taxCustomRateInvalid": "Ingresá una tasa entre 0 y 100",
+ "settings.taxNotConfigured": "No configurado · comportamiento heredado",
+ "settings.taxMerchantOverrides": "Overrides del comerciante",
+ "settings.taxMerchantOverridesHint": "Los overrides tienen prioridad sobre las asignaciones de producto y categoría, pero las exenciones de transacción siguen ganando.",
+ "settings.taxChooseTarget": "Elegí {entity}",
+ "settings.taxStoreWideCharge": "Cargo de todo el local",
+ "settings.taxSaveOverride": "Guardar override",
+ "settings.taxAddOverride": "Agregar override",
+ "settings.taxTarget": "Destino",
+ "settings.taxCategory": "Categoría",
+ "settings.taxUpdated": "Actualizado",
+ "settings.taxActions": "Acciones",
+ "settings.taxStoreWide": "Todo el local",
+ "settings.taxReadOnly": "Solo lectura",
+ "settings.taxNoOverrides": "No hay overrides del comerciante. Se está usando el comportamiento oficial del paquete.",
+ "settings.taxPackReference": "Referencia del paquete",
+ "settings.taxPackReferenceHint": "Categorías y reglas de solo lectura del paquete JSON instalado activo.",
+ "settings.taxDefaultBehavior": "Comportamiento predeterminado",
+ "settings.taxRules": "Reglas",
+ "settings.taxNone": "Ninguna",
+ "settings.taxPackDefault": "Predeterminado del paquete",
+ "settings.taxRule": "Regla",
+ "settings.taxType": "Tipo",
+ "settings.taxValue": "Valor",
+ "settings.taxScope": "Alcance",
+ "settings.taxDependsOn": "Depende de",
+ "settings.taxAuditHistory": "Historial de auditoría",
+ "settings.taxUnknownUser": "Usuario desconocido",
+ "settings.taxSystem": "Sistema",
+ "settings.taxNoAudit": "No se registraron cambios en la configuración fiscal.",
+ "settings.taxLoadFailed": "No se pudo cargar la configuración fiscal",
+ "settings.taxLoadDetailFailed": "No se pudieron cargar los detalles del paquete fiscal",
+ "settings.taxChooseTargetAndCategory": "Elegí un destino y una categoría fiscal",
+ "settings.taxOverrideUpdated": "Override fiscal actualizado",
+ "settings.taxOverrideAdded": "Override fiscal agregado",
+ "settings.taxOverrideSaveFailed": "No se pudo guardar el override fiscal",
+ "settings.taxRemoveOverrideConfirm": "¿Quitar el override de {entity}?",
+ "settings.taxOverrideRemoved": "Override fiscal eliminado; se restauró el comportamiento oficial del paquete",
+ "settings.taxOverrideRemoveFailed": "No se pudo eliminar el override fiscal",
+ "settings.taxStoreWideDefaultRestored": "Predeterminado de {entity} restablecido",
+ "settings.taxStoreWideDefaultSaved": "Predeterminado de {entity} guardado",
+ "settings.taxStoreWideDefaultSaveFailed": "No se pudo guardar el predeterminado",
+ "settings.taxVersionActivated": "Versión del paquete fiscal instalada activada",
+ "settings.taxVersionActivateFailed": "No se pudo activar la versión del paquete fiscal",
+ "settings.taxRollbackConfirm": "¿Revertir a la versión instalada anterior?",
+ "settings.taxRolledBack": "Paquete fiscal revertido",
+ "settings.taxRollbackFailed": "No se pudo revertir el paquete fiscal",
+ "settings.taxUpToDate": "Los paquetes fiscales instalados están actualizados",
+ "settings.taxCheckUpdatesFailed": "No se pudieron buscar actualizaciones de paquetes fiscales",
+ "settings.taxPackInstalled": "Paquete fiscal {id} v{version} verificado e instalado",
+ "settings.taxPackInstallFailed": "No se pudo instalar la actualización del paquete fiscal",
+ "settings.taxPackNoInstallable": "El paquete fiscal seleccionado no tiene una versión instalable",
+ "settings.taxEnabledWith": "Impuestos habilitados con {id} v{version}",
+ "settings.taxEnableTaxesFailed": "No se pudieron habilitar los impuestos para este país",
+ "settings.taxCalculateFailed": "No se pudo calcular el impuesto",
+ "settings.taxEntityProduct": "Producto",
+ "settings.taxEntityAddon": "Adicional",
+ "settings.taxEntityPackaging": "Cargo de empaque",
+ "settings.taxEntityDelivery": "Cargo de envío",
+ "settings.taxEntityServiceCharge": "Cargo por servicio",
+ "settings.taxAuditBundledInstalled": "Paquete del bundle instalado",
+ "settings.taxAuditDownloadedInstalled": "Paquete descargado instalado",
+ "settings.taxAuditPackActivated": "Paquete activado",
+ "settings.taxAuditPackRolledBack": "Paquete revertido",
+ "settings.taxAuditOverrideAdded": "Override agregado",
+ "settings.taxAuditOverrideEdited": "Override editado",
+ "settings.taxAuditOverrideRemoved": "Override eliminado",
+ "settings.taxAuditDescBundledInstalled": "Versión {version} del paquete de la aplicación",
+ "settings.taxAuditDescDownloadedInstalled": "Versión {version} verificada e instalada desde GitHub Releases",
+ "settings.taxAuditDescOverrideAdded": "{entityType} {entityId} → {categoryId}",
+ "settings.taxAuditDescOverrideEdited": "{entityType} {entityId}: {before} → {after}",
+ "settings.taxAuditDescOverrideRemoved": "{entityType} {entityId} volvió al comportamiento oficial",
+ "settings.taxAuditDescActivated": "Versión anterior: {previousVersionId}",
+ "settings.taxAuditDescRolledBack": "Revertido desde {previousVersionId}",
"settings.orderNumberFormat": "Formato del número de pedido",
"settings.orderNumberPrefix": "Prefijo",
"settings.orderNumberPreview": "Vista previa",
diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json
index 97fbc1e1..9a1fd56f 100644
--- a/frontend/src/lib/i18n/pt.json
+++ b/frontend/src/lib/i18n/pt.json
@@ -756,6 +756,134 @@
"settings.status": "Status",
"settings.storeDetails": "Detalhes da Loja",
"settings.taxConfiguration": "Configuração tributária",
+ "settings.taxLoading": "Carregando configuração tributária…",
+ "settings.taxConfigSubtitle": "Revise os pacotes de país instalados, aplique overrides do comerciante e teste cálculos.",
+ "settings.taxCheckForUpdates": "Verificar atualizações",
+ "settings.taxChecking": "Verificando…",
+ "settings.taxRefresh": "Atualizar",
+ "settings.taxManagerNotice": "Gerentes podem revisar pacotes, overrides, histórico de auditoria e executar cálculos de teste. Apenas os donos podem fazer alterações.",
+ "settings.taxNotEnabledTitle": "Os impostos do país não estão habilitados",
+ "settings.taxNotEnabledBody": "O FloCafe está usando o perfil genérico sem impostos. Um dono pode baixar, verificar e ativar um pacote oficial para {country} quando houver acesso à internet.",
+ "settings.taxOwnerOnlyEnable": "Apenas donos podem habilitar os impostos",
+ "settings.taxEnabling": "Habilitando…",
+ "settings.taxEnableTaxes": "Habilitar impostos",
+ "settings.taxCountryPackUnavailable": "Nenhum pacote fiscal oficial disponível para {country}. Mantenha os impostos desabilitados ou use a configuração genérica/manual.",
+ "settings.taxAvailablePacks": "Pacotes fiscais disponíveis",
+ "settings.taxAvailablePacksHint": "Os downloads são verificados com a chave de assinatura integrada do FloCafe antes da instalação.",
+ "settings.taxGeneric": "Genérico",
+ "settings.taxPublished": "Publicado em {date}",
+ "settings.taxRequiresFlo": "Requer FloCafe {version}+",
+ "settings.taxVerifyAndInstall": "Verificar e instalar",
+ "settings.taxOwnerOnlyInstall": "Apenas donos podem instalar pacotes fiscais",
+ "settings.taxNoUpdates": "Não há versões mais recentes de pacotes fiscais disponíveis.",
+ "settings.taxInstalledCountryPacks": "Pacotes de país instalados",
+ "settings.taxStoreCountry": "País da loja",
+ "settings.taxJurisdiction": "Jurisdição",
+ "settings.taxActiveVersion": "Versão ativa",
+ "settings.taxTrustStatus": "Status de confiança",
+ "settings.taxEffective": "Vigente desde {date}",
+ "settings.taxActivationChecksPassed": "{passed} de {total} verificações de ativação aprovadas",
+ "settings.taxActivationChecksFailed": "Falha nas verificações de ativação",
+ "settings.taxNoActiveVersion": "Este pacote ainda não tem uma versão ativa — ative uma versão instalada abaixo.",
+ "settings.taxInstalledVersions": "Versões instaladas",
+ "settings.taxNoPreviousVersion": "Nenhuma versão instalada anterior disponível",
+ "settings.taxRollback": "Reverter",
+ "settings.taxActivateVersion": "Ativar versão instalada",
+ "settings.taxNoActivePack": "Nenhum pacote instalado ativo disponível.",
+ "settings.taxTestCalculation": "Cálculo de teste",
+ "settings.taxTestCalculationHint": "Usa o pacote ativo e o mesmo motor fiscal do checkout. Não salva uma transação.",
+ "settings.taxNotActivePackTest": "Este pacote instalado não está ativo para a loja atual. Selecione o pacote ativo para testá-lo.",
+ "settings.taxCountryDefault": "Padrão do país",
+ "settings.taxExclusive": "Imposto exclusivo",
+ "settings.taxInclusive": "Imposto incluído",
+ "settings.taxExempt": "Isento",
+ "settings.taxCalculate": "Calcular",
+ "settings.taxInvalidAmount": "Informe um valor positivo válido para o cálculo de teste",
+ "settings.taxNeedActivePack": "Selecione o pacote do país ativo para executar um cálculo de checkout",
+ "settings.taxTaxableBase": "Base tributável",
+ "settings.taxPayableTotal": "Total a pagar",
+ "settings.taxStoreWideDefaults": "Padrões fiscais da loja",
+ "settings.taxStoreWideDefaultsHint": "Categoria padrão para produtos e adicionais sem categoria própria, e para cobranças no nível do pedido. Itens não configurados voltam ao padrão do pacote.",
+ "settings.taxNeedActivePackDefaults": "Selecione o pacote do país ativo para alterar os padrões da loja.",
+ "settings.taxApplyToAll": "Padrão para tudo",
+ "settings.taxApplyToAllHint": "Escolha uma categoria e aplique-a a todos os tipos (produtos, adicionais e cobranças) de uma vez.",
+ "settings.taxApplyToAllButton": "Aplicar a tudo",
+ "settings.taxApplyToAllSuccess": "{category} aplicado a todos os padrões da loja",
+ "settings.taxCustomRate": "Taxa personalizada",
+ "settings.taxCustomRateInvalid": "Informe uma taxa entre 0 e 100",
+ "settings.taxNotConfigured": "Não configurado · comportamento legado",
+ "settings.taxMerchantOverrides": "Overrides do comerciante",
+ "settings.taxMerchantOverridesHint": "Os overrides têm prioridade sobre as atribuições de produto e categoria, mas as isenções de transação ainda prevalecem.",
+ "settings.taxChooseTarget": "Escolha {entity}",
+ "settings.taxStoreWideCharge": "Cobrança para toda a loja",
+ "settings.taxSaveOverride": "Salvar override",
+ "settings.taxAddOverride": "Adicionar override",
+ "settings.taxTarget": "Destino",
+ "settings.taxCategory": "Categoria",
+ "settings.taxUpdated": "Atualizado",
+ "settings.taxActions": "Ações",
+ "settings.taxStoreWide": "Toda a loja",
+ "settings.taxReadOnly": "Somente leitura",
+ "settings.taxNoOverrides": "Nenhum override do comerciante. O comportamento oficial do pacote está em uso.",
+ "settings.taxPackReference": "Referência do pacote",
+ "settings.taxPackReferenceHint": "Categorias e regras somente leitura do pacote JSON instalado ativo.",
+ "settings.taxDefaultBehavior": "Comportamento padrão",
+ "settings.taxRules": "Regras",
+ "settings.taxNone": "Nenhuma",
+ "settings.taxPackDefault": "Padrão do pacote",
+ "settings.taxRule": "Regra",
+ "settings.taxType": "Tipo",
+ "settings.taxValue": "Valor",
+ "settings.taxScope": "Escopo",
+ "settings.taxDependsOn": "Depende de",
+ "settings.taxAuditHistory": "Histórico de auditoria",
+ "settings.taxUnknownUser": "Usuário desconhecido",
+ "settings.taxSystem": "Sistema",
+ "settings.taxNoAudit": "Nenhuma alteração na configuração fiscal registrada.",
+ "settings.taxLoadFailed": "Não foi possível carregar a configuração tributária",
+ "settings.taxLoadDetailFailed": "Não foi possível carregar os detalhes do pacote fiscal",
+ "settings.taxChooseTargetAndCategory": "Escolha um destino e uma categoria fiscal",
+ "settings.taxOverrideUpdated": "Override fiscal atualizado",
+ "settings.taxOverrideAdded": "Override fiscal adicionado",
+ "settings.taxOverrideSaveFailed": "Não foi possível salvar o override fiscal",
+ "settings.taxRemoveOverrideConfirm": "Remover o override de {entity}?",
+ "settings.taxOverrideRemoved": "Override fiscal removido; comportamento oficial do pacote restaurado",
+ "settings.taxOverrideRemoveFailed": "Não foi possível remover o override fiscal",
+ "settings.taxStoreWideDefaultRestored": "Padrão de {entity} restaurado",
+ "settings.taxStoreWideDefaultSaved": "Padrão de {entity} salvo",
+ "settings.taxStoreWideDefaultSaveFailed": "Não foi possível salvar o padrão",
+ "settings.taxVersionActivated": "Versão do pacote fiscal instalado ativada",
+ "settings.taxVersionActivateFailed": "Não foi possível ativar a versão do pacote fiscal",
+ "settings.taxRollbackConfirm": "Reverter para a versão instalada anterior?",
+ "settings.taxRolledBack": "Pacote fiscal revertido",
+ "settings.taxRollbackFailed": "Não foi possível reverter o pacote fiscal",
+ "settings.taxUpToDate": "Os pacotes fiscais instalados estão atualizados",
+ "settings.taxCheckUpdatesFailed": "Não foi possível verificar atualizações de pacotes fiscais",
+ "settings.taxPackInstalled": "Pacote fiscal {id} v{version} verificado e instalado",
+ "settings.taxPackInstallFailed": "Não foi possível instalar a atualização do pacote fiscal",
+ "settings.taxPackNoInstallable": "O pacote fiscal selecionado não tem uma versão instalável",
+ "settings.taxEnabledWith": "Impostos habilitados com {id} v{version}",
+ "settings.taxEnableTaxesFailed": "Não foi possível habilitar os impostos para este país",
+ "settings.taxCalculateFailed": "Não foi possível calcular o imposto",
+ "settings.taxEntityProduct": "Produto",
+ "settings.taxEntityAddon": "Adicional",
+ "settings.taxEntityPackaging": "Cobrança de embalagem",
+ "settings.taxEntityDelivery": "Cobrança de entrega",
+ "settings.taxEntityServiceCharge": "Taxa de serviço",
+ "settings.taxAuditBundledInstalled": "Pacote integrado instalado",
+ "settings.taxAuditDownloadedInstalled": "Pacote baixado instalado",
+ "settings.taxAuditPackActivated": "Pacote ativado",
+ "settings.taxAuditPackRolledBack": "Pacote revertido",
+ "settings.taxAuditOverrideAdded": "Override adicionado",
+ "settings.taxAuditOverrideEdited": "Override editado",
+ "settings.taxAuditOverrideRemoved": "Override removido",
+ "settings.taxAuditDescBundledInstalled": "Versão {version} do pacote do aplicativo",
+ "settings.taxAuditDescDownloadedInstalled": "Versão {version} verificada e instalada a partir do GitHub Releases",
+ "settings.taxAuditDescOverrideAdded": "{entityType} {entityId} → {categoryId}",
+ "settings.taxAuditDescOverrideEdited": "{entityType} {entityId}: {before} → {after}",
+ "settings.taxAuditDescOverrideRemoved": "{entityType} {entityId} voltou ao comportamento oficial",
+ "settings.taxAuditDescActivated": "Versão anterior: {previousVersionId}",
+ "settings.taxAuditDescRolledBack": "Revertido de {previousVersionId}",
"settings.orderNumberFormat": "Formato do Número do Pedido",
"settings.orderNumberPrefix": "Prefixo",
"settings.orderNumberPreview": "Pré-visualização",
diff --git a/main/routes/auth.ts b/main/routes/auth.ts
index c9b0786e..dab08c95 100644
--- a/main/routes/auth.ts
+++ b/main/routes/auth.ts
@@ -253,17 +253,17 @@ function seedDemoRestaurant(db: ReturnType, serviceModel: st
}
if (lang === 'es') {
- insertCustomer(db, 'cust-demo-1', 'Juan Pérez', '1145678901', dialCode);
- insertCustomer(db, 'cust-demo-2', 'María González', '1145678902', dialCode);
- insertCustomer(db, 'cust-demo-3', 'Carlos Rodríguez', '1145678903', dialCode);
+ insertCustomer(db, 'cust-demo-1', 'Juan Pérez', `${dialCode}1145678901`, dialCode);
+ insertCustomer(db, 'cust-demo-2', 'María González', `${dialCode}1145678902`, dialCode);
+ insertCustomer(db, 'cust-demo-3', 'Carlos Rodríguez', `${dialCode}1145678903`, dialCode);
} else if (lang === 'pt') {
- insertCustomer(db, 'cust-demo-1', 'João Silva', '1198765432', dialCode);
- insertCustomer(db, 'cust-demo-2', 'Maria Santos', '1198765433', dialCode);
- insertCustomer(db, 'cust-demo-3', 'Carlos Oliveira', '1198765434', dialCode);
+ insertCustomer(db, 'cust-demo-1', 'João Silva', `${dialCode}1198765432`, dialCode);
+ insertCustomer(db, 'cust-demo-2', 'Maria Santos', `${dialCode}1198765433`, dialCode);
+ insertCustomer(db, 'cust-demo-3', 'Carlos Oliveira', `${dialCode}1198765434`, dialCode);
} else {
- insertCustomer(db, 'cust-demo-1', 'Aarav Sharma', '9876543210', dialCode);
- insertCustomer(db, 'cust-demo-2', 'Maya Iyer', '9876543211', dialCode);
- insertCustomer(db, 'cust-demo-3', 'Kabir Khan', '9876543212', dialCode);
+ insertCustomer(db, 'cust-demo-1', 'Aarav Sharma', `${dialCode}9876543210`, dialCode);
+ insertCustomer(db, 'cust-demo-2', 'Maya Iyer', `${dialCode}9876543211`, dialCode);
+ insertCustomer(db, 'cust-demo-3', 'Kabir Khan', `${dialCode}9876543212`, dialCode);
}
const managerName = lang === 'es' ? 'Gerente Demo' : lang === 'pt' ? 'Gerente Demo' : 'Demo Manager';
diff --git a/main/routes/tax-packs.ts b/main/routes/tax-packs.ts
index 63c92141..5182ef4f 100644
--- a/main/routes/tax-packs.ts
+++ b/main/routes/tax-packs.ts
@@ -134,21 +134,19 @@ function validateOverrideTarget(
}
const normalizedType = entityType as OverrideEntityType;
- const requiresEntity = normalizedType === 'product' || normalizedType === 'addon';
const normalizedEntityId = entityId === null || entityId === undefined || entityId === ''
? null
: String(entityId);
- if (requiresEntity && !normalizedEntityId) {
- throw Object.assign(new Error(`entity_id is required for ${normalizedType} overrides`), { statusCode: 400 });
- }
- if (!requiresEntity && normalizedEntityId) {
- throw Object.assign(new Error(`entity_id must be empty for ${normalizedType} overrides`), { statusCode: 400 });
- }
- if (normalizedType === 'product') {
+ // entity_id is optional for every entity type. A null entity_id means the
+ // override is the store-wide default for that kind (used by calculateItemTax
+ // as the fallback when a product/addon has no per-item override and no own
+ // tax_category). The unique-target check (entity_id IS ?) prevents two
+ // store-wide rows per kind.
+ if (normalizedType === 'product' && normalizedEntityId) {
const product = db.prepare('SELECT 1 FROM products WHERE id = ? AND deleted_at IS NULL').get(normalizedEntityId);
if (!product) throw Object.assign(new Error('Product not found'), { statusCode: 404 });
}
- if (normalizedType === 'addon') {
+ if (normalizedType === 'addon' && normalizedEntityId) {
const addon = db.prepare('SELECT 1 FROM addons WHERE id = ?').get(normalizedEntityId);
if (!addon) throw Object.assign(new Error('Add-on not found'), { statusCode: 404 });
}
@@ -420,6 +418,7 @@ export function validationChecklist(
FROM tax_overrides
WHERE pack_version_id = ?
AND json_extract(value_json, '$.categoryId') NOT IN (${categoryIds.map(() => '?').join(',') || "''"})
+ AND json_extract(value_json, '$.categoryId') NOT LIKE 'merchant:%'
`).get(activeVersion.active_version_id, ...categoryIds) as { count: number } : { count: 0 };
add(20, overrideConflicts.count === 0, 'Every current merchant override resolves against this version');
add(21, pack.categories.every((category) => Boolean(category.label))
@@ -733,6 +732,88 @@ router.post('/test-calculation', requireRole('owner', 'manager'), (req: Request,
}
});
+// Onboard a merchant-defined rate as a category + rule that the engine can
+// resolve, then point every store-wide default at it. Idempotent: reapplying
+// the same rate reuses the existing row. The merchant rows are tagged with
+// the "merchant:" prefix so activation can migrate them and the install
+// validator can ignore them.
+router.post('/merchant-rate', requireRole('owner'), (req: Request, res: Response) => {
+ try {
+ const country = getSettingValue('country') || 'IN';
+ const active = activePackForCountry(country);
+ const rawRate = typeof req.body?.rate === 'string' ? req.body.rate.trim() : '';
+ if (!rawRate) {
+ throw Object.assign(new Error('rate is required'), { statusCode: 400 });
+ }
+ const rateNumber = Number(rawRate);
+ if (!Number.isFinite(rateNumber) || rateNumber <= 0 || rateNumber > 100) {
+ throw Object.assign(new Error('rate must be a number between 0 and 100'), { statusCode: 400 });
+ }
+ const rateText = rawRate.replace(/[^\d.]/g, '');
+ const categoryId = `merchant:${rateText}`;
+ const ruleId = `merchant:rate-${rateText}`;
+
+ const db = getDatabase();
+ withTxn(() => {
+ db.prepare(`
+ INSERT OR IGNORE INTO tax_rules
+ (id, pack_version_id, rule_id, label, calculation_type, rate, amount, applies_per, base_rule_ids, definition_json)
+ VALUES (?, ?, ?, ?, 'percent', ?, NULL, 'line', NULL, '{}')
+ `).run(`rule-${ruleId}-${active.version.id}`, active.version.id, ruleId, `Custom ${rateText}%`, rateText);
+
+ // Category row stores ruleIds in definition_json so the engine's
+ // category→ruleIds walk picks the rule up. INSERT OR REPLACE is
+ // idempotent: reapplying the same rate reuses the same row.
+ db.prepare(`
+ INSERT OR REPLACE INTO tax_categories
+ (id, pack_version_id, category_id, label, default_behavior, definition_json)
+ VALUES (?, ?, ?, ?, NULL, ?)
+ `).run(
+ `cat-${categoryId}-${active.version.id}`,
+ active.version.id,
+ categoryId,
+ `Custom ${rateText}%`,
+ JSON.stringify({ ruleIds: [ruleId] }),
+ );
+
+ const valueJson = JSON.stringify({ categoryId });
+ for (const entityType of ENTITY_TYPES) {
+ const existing = db.prepare(`
+ SELECT id FROM tax_overrides
+ WHERE pack_version_id = ? AND entity_type = ? AND entity_id IS NULL
+ AND field_name = 'tax_category_id'
+ `).get(active.version.id, entityType) as { id: string } | undefined;
+ if (existing) {
+ db.prepare(`
+ UPDATE tax_overrides SET value_json = ?, updated_at = ?
+ WHERE id = ?
+ `).run(valueJson, now(), existing.id);
+ } else {
+ db.prepare(`
+ INSERT INTO tax_overrides (
+ id, pack_version_id, entity_type, entity_id, field_name, value_json,
+ created_by_user_id, created_at, updated_at
+ ) VALUES (?, ?, ?, NULL, 'tax_category_id', ?, ?, ?, ?)
+ `).run(
+ randomUUID(),
+ active.version.id,
+ entityType,
+ valueJson,
+ actorUserId(req),
+ now(),
+ now(),
+ );
+ }
+ }
+ });
+
+ res.status(201).json({ categoryId, rate: rateText });
+ } catch (error: any) {
+ const statusCode = error.statusCode || 400;
+ res.status(statusCode).json({ error: error.message || 'Could not apply merchant rate' });
+ }
+});
+
router.post('/overrides', requireRole('owner'), (req: Request, res: Response) => {
try {
const country = getSettingValue('country') || 'IN';
@@ -882,6 +963,21 @@ router.post('/:packId/versions/:versionId/activate', requireRole('owner'), (req:
).run(now(), pack.country, pack.id);
if (previousVersionId) {
db.prepare(`UPDATE country_pack_versions SET status = 'installed' WHERE id = ?`).run(previousVersionId);
+ // Migrate merchant-defined categories and rules to the new version so
+ // store-wide overrides pointing at them keep resolving. Official
+ // categories/rules are loaded from the new pack_json on demand.
+ db.prepare(`
+ INSERT OR IGNORE INTO tax_categories
+ (id, pack_version_id, category_id, label, default_behavior, definition_json)
+ SELECT id, ?, category_id, label, default_behavior, definition_json
+ FROM tax_categories WHERE pack_version_id = ? AND category_id LIKE 'merchant:%'
+ `).run(version.id, previousVersionId);
+ db.prepare(`
+ INSERT OR IGNORE INTO tax_rules
+ (id, pack_version_id, rule_id, label, calculation_type, rate, amount, applies_per, base_rule_ids, definition_json)
+ SELECT id, ?, rule_id, label, calculation_type, rate, amount, applies_per, base_rule_ids, definition_json
+ FROM tax_rules WHERE pack_version_id = ? AND rule_id LIKE 'merchant:%'
+ `).run(version.id, previousVersionId);
db.prepare(`
UPDATE tax_overrides SET pack_version_id = ?, updated_at = ?
WHERE pack_version_id = ?
diff --git a/main/services/tax.ts b/main/services/tax.ts
index 7de174d0..06a9a714 100644
--- a/main/services/tax.ts
+++ b/main/services/tax.ts
@@ -77,22 +77,71 @@ function round(value: number, decimals: number = 2): number {
// by the GET /api/tax/categories endpoint (routes/index.ts) — kept as one
// function so the two never drift apart on which pack is "active".
export function getActiveCountryPack(country: string): CountryPack {
+ let pack: CountryPack | null = null;
try {
const db = getDatabase();
const row = db.prepare(`
- SELECT version.pack_json
+ SELECT version.id AS version_id, version.pack_json
FROM country_packs AS pack
JOIN country_pack_versions AS version ON version.id = pack.active_version_id
WHERE pack.country IN (?, '*') AND pack.status = 'active'
ORDER BY CASE WHEN pack.country = ? THEN 0 ELSE 1 END, pack.updated_at DESC
LIMIT 1
- `).get(country, country) as { pack_json: string } | undefined;
- if (row) return JSON.parse(row.pack_json) as CountryPack;
+ `).get(country, country) as { version_id: string; pack_json: string } | undefined;
+ if (row) {
+ pack = JSON.parse(row.pack_json) as CountryPack;
+ // Merge merchant-defined categories and rules so the engine can resolve
+ // them. They are tagged with the "merchant:" prefix and were created by
+ // POST /tax-packs/merchant-rate. The category row stores ruleIds in
+ // definition_json (the schema this code uses elsewhere on the panel).
+ const merchantCategories = db.prepare(`
+ SELECT category_id, label, default_behavior, definition_json
+ FROM tax_categories WHERE pack_version_id = ? AND category_id LIKE 'merchant:%'
+ `).all(row.version_id) as Array<{ category_id: string; label: string; default_behavior: string | null; definition_json: string }>;
+ const merchantRules = db.prepare(`
+ SELECT rule_id, label, calculation_type, rate, amount
+ FROM tax_rules WHERE pack_version_id = ? AND rule_id LIKE 'merchant:%'
+ `).all(row.version_id) as Array<{ rule_id: string; label: string; calculation_type: string; rate: string | null; amount: string | null }>;
+ if (merchantCategories.length || merchantRules.length) {
+ const ruleIdByCategory = new Map();
+ for (const row of merchantCategories) {
+ try {
+ const def = JSON.parse(row.definition_json || '{}') as { ruleIds?: string[] };
+ if (Array.isArray(def.ruleIds)) ruleIdByCategory.set(row.category_id, def.ruleIds);
+ } catch { /* ignore */ }
+ }
+ pack = {
+ ...pack,
+ categories: [
+ ...pack.categories,
+ ...merchantCategories.map((c) => ({
+ id: c.category_id,
+ label: c.label,
+ ruleIds: ruleIdByCategory.get(c.category_id) || [],
+ ...(c.default_behavior ? { defaultBehavior: c.default_behavior as never } : {}),
+ })),
+ ],
+ rules: [
+ ...pack.rules,
+ ...merchantRules.map((r) => ({
+ id: r.rule_id,
+ label: r.label,
+ type: r.calculation_type as 'percent' | 'fixed',
+ categoryIds: merchantCategories.filter((c) => ruleIdByCategory.get(c.category_id)?.includes(r.rule_id)).map((c) => c.category_id),
+ ...(r.rate !== null ? { rate: r.rate } : {}),
+ ...(r.amount !== null ? { amount: r.amount } : {}),
+ appliesPer: 'line' as const,
+ })),
+ ],
+ };
+ }
+ }
} catch {
// Database initialization and isolated unit tests can call this before the
// migration runner has registered bundled packs. The immutable bundled
// JSON remains the required offline fallback.
}
+ if (pack) return pack;
return getBundledCountryPack(country);
}
@@ -110,13 +159,14 @@ export function calculateItemTax(
taxableAmount: number,
customer: Customer | null
): TaxResult {
- const taxCategoryId = product.tax_category_id || product.tax_category;
+ const ownCategoryId = product.tax_category_id || product.tax_category;
const pack = getActiveCountryPack(tenant.country);
let merchantOverride: { id: string; categoryId: string } | null = null;
- if (product.id !== undefined && product.id !== null) {
- try {
- const db = getDatabase();
- const row = db.prepare(`
+ let storeWideCategoryId: string | null = null;
+ try {
+ const db = getDatabase();
+ if (product.id !== undefined && product.id !== null) {
+ const overrideRow = db.prepare(`
SELECT override.id, override.value_json
FROM tax_overrides AS override
JOIN country_packs AS pack ON pack.active_version_id = override.pack_version_id
@@ -127,19 +177,45 @@ export function calculateItemTax(
ORDER BY override.updated_at DESC
LIMIT 1
`).get(pack.id, String(product.id)) as { id: string; value_json: string } | undefined;
- if (row) {
- const value = JSON.parse(row.value_json);
+ if (overrideRow) {
+ const value = JSON.parse(overrideRow.value_json);
const categoryId = typeof value === 'string' ? value : value?.categoryId;
if (typeof categoryId === 'string' && categoryId) {
- merchantOverride = { id: row.id, categoryId };
+ merchantOverride = { id: overrideRow.id, categoryId };
}
}
- } catch {
- // An unavailable override table is possible only before migrations or
- // in isolated unit tests; normal checkout databases always have it.
}
+ if (!ownCategoryId && !merchantOverride) {
+ // Store-wide default: consulted only when the product has no own
+ // category and no per-product override. Priority (matches the engine's
+ // resolveTaxCategory chain): per-product override > product's own
+ // tax_category > store-wide default > untaxed.
+ const storeWideRow = db.prepare(`
+ SELECT override.id, override.value_json
+ FROM tax_overrides AS override
+ JOIN country_packs AS pack ON pack.active_version_id = override.pack_version_id
+ WHERE pack.id = ?
+ AND override.entity_type = 'product'
+ AND override.entity_id IS NULL
+ AND override.field_name = 'tax_category_id'
+ ORDER BY override.updated_at DESC
+ LIMIT 1
+ `).get(pack.id) as { id: string; value_json: string } | undefined;
+ if (storeWideRow) {
+ const value = JSON.parse(storeWideRow.value_json);
+ const categoryId = typeof value === 'string' ? value : value?.categoryId;
+ if (typeof categoryId === 'string' && categoryId) {
+ storeWideCategoryId = categoryId;
+ }
+ }
+ }
+ } catch {
+ // An unavailable override table is possible only before migrations or
+ // in isolated unit tests; normal checkout databases always have it.
}
+ const taxCategoryId = ownCategoryId || storeWideCategoryId;
+
if (taxCategoryId || merchantOverride) {
let calculation;
try {
@@ -161,7 +237,7 @@ export function calculateItemTax(
quantity: '1',
unitPrice: String(taxableAmount),
merchantCategoryId: merchantOverride?.categoryId,
- productCategoryId: taxCategoryId,
+ productCategoryId: taxCategoryId ?? undefined,
taxBehavior: product.tax_behavior || 'country_default',
}],
});
diff --git a/tests/tax-pack-management.test.ts b/tests/tax-pack-management.test.ts
index bcb9ba76..07e0deb5 100644
--- a/tests/tax-pack-management.test.ts
+++ b/tests/tax-pack-management.test.ts
@@ -212,6 +212,196 @@ async function main() {
assertEqual(orderWithoutOverride.data.order.tax_amount, 0, 'reset removes merchant category assignment');
assert(!orderWithoutOverride.data.order.items[0].tax_snapshot, 'reset returns the product to the no-tax path');
+ console.log('\n4b. Store-wide product default resolves at checkout with proper priority');
+ // No per-product override exists now (it was deleted above). Seed a
+ // second product with no own category and no per-product override, then
+ // create a store-wide product override (entity_id null) and verify an
+ // order through it picks up the standard 5% rate.
+ seedProduct(db, 'store-default-product', 'tax-pack-products', 'Store Default Product', 200, {
+ tax_type: 'none',
+ tax_category_id: null,
+ });
+
+ const storeWideCreate = await api(baseUrl, '/api/tax-packs/overrides', {
+ method: 'POST',
+ body: {
+ entity_type: 'product',
+ entity_id: null,
+ category_id: 'standard',
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(storeWideCreate.status, 201, 'owner can create a store-wide product override (entity_id null)');
+
+ const orderWithStoreDefault = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'store-default-product', quantity: 1 }],
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(orderWithStoreDefault.status, 201, 'uncategorized product checks out through the store-wide default');
+ assertEqual(orderWithStoreDefault.data.order.tax_amount, 10, 'store-wide default applies the standard 5% rate on a 200-line subtotal');
+ const storeDefaultSnapshotRaw = orderWithStoreDefault.data.order.items[0].tax_snapshot;
+ const storeDefaultSnapshot = typeof storeDefaultSnapshotRaw === 'string'
+ ? JSON.parse(storeDefaultSnapshotRaw) : storeDefaultSnapshotRaw;
+ assertEqual(storeDefaultSnapshot.lines[0].categorySource, 'explicit', 'store-wide default resolves through the explicit (product-category) slot, not the merchant-override slot');
+
+ // Priority: a per-product override must beat the store-wide default.
+ seedProduct(db, 'priority-product', 'tax-pack-products', 'Priority Product', 100, {
+ tax_type: 'none',
+ tax_category_id: null,
+ });
+ const productOverrideOnTop = await api(baseUrl, '/api/tax-packs/overrides', {
+ method: 'POST',
+ body: {
+ entity_type: 'product',
+ entity_id: 'priority-product',
+ category_id: 'standard',
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(productOverrideOnTop.status, 201, 'per-product override is created on top of the store-wide default');
+ const priorityOrder = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'priority-product', quantity: 1 }],
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(priorityOrder.status, 201, 'per-product override + store-wide default both apply');
+ const prioritySnapshotRaw = priorityOrder.data.order.items[0].tax_snapshot;
+ const prioritySnapshot = typeof prioritySnapshotRaw === 'string' ? JSON.parse(prioritySnapshotRaw) : prioritySnapshotRaw;
+ assertEqual(prioritySnapshot.lines[0].categorySource, 'merchant_override', 'per-product override beats the store-wide default');
+ assertEqual(priorityOrder.data.order.tax_amount, 5, 'per-product override wins (5% on 100)');
+
+ // Priority: a product's own tax_category must beat the store-wide default.
+ seedProduct(db, 'own-category-product', 'tax-pack-products', 'Own Category Product', 100, {
+ tax_type: 'inclusive',
+ tax_category_id: 'standard',
+ tax_behavior: 'inclusive',
+ });
+ const ownCategoryOrder = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'own-category-product', quantity: 1 }],
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(ownCategoryOrder.status, 201, 'a product with its own tax_category still checks out');
+ const ownCategorySnapshotRaw = ownCategoryOrder.data.order.items[0].tax_snapshot;
+ const ownCategorySnapshot = typeof ownCategorySnapshotRaw === 'string' ? JSON.parse(ownCategorySnapshotRaw) : ownCategorySnapshotRaw;
+ assertEqual(ownCategorySnapshot.lines[0].categorySource, 'explicit', 'product owns its own category assignment');
+
+ // Negative: a product with no priority of any kind still gets the store-wide default.
+ const stillStoreDefault = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'store-default-product', quantity: 1 }],
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(stillStoreDefault.data.order.tax_amount, 10, 'a second uncategorized product picks up the same store-wide default');
+
+ console.log('\n4c. Custom merchant rate applies to all kinds with one call');
+ const managerMerchantRate = await api(baseUrl, '/api/tax-packs/merchant-rate', {
+ method: 'POST',
+ body: { rate: '10.5' },
+ headers: manager.authHeader,
+ });
+ assertEqual(managerMerchantRate.status, 403, 'manager cannot create a custom rate');
+
+ const ownerMerchantRate = await api(baseUrl, '/api/tax-packs/merchant-rate', {
+ method: 'POST',
+ body: { rate: '10.5' },
+ headers: owner.authHeader,
+ });
+ assertEqual(ownerMerchantRate.status, 201, 'owner applies a custom merchant rate');
+ assertEqual(ownerMerchantRate.data.categoryId, 'merchant:10.5', 'category id is named after the rate');
+
+ const duplicateMerchantRate = await api(baseUrl, '/api/tax-packs/merchant-rate', {
+ method: 'POST',
+ body: { rate: '10.5' },
+ headers: owner.authHeader,
+ });
+ assertEqual(duplicateMerchantRate.status, 201, 're-applying the same rate is idempotent');
+
+ const invalidMerchantRates = [
+ { rate: '0' },
+ { rate: '101' },
+ { rate: 'abc' },
+ { rate: '' },
+ ];
+ for (const body of invalidMerchantRates) {
+ const response = await api(baseUrl, '/api/tax-packs/merchant-rate', {
+ method: 'POST',
+ body,
+ headers: owner.authHeader,
+ });
+ assertEqual(response.status, 400, `invalid rate "${body.rate}" is rejected`);
+ }
+
+ const merchantRateOrder = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'store-default-product', quantity: 1 }],
+ },
+ headers: owner.authHeader,
+ });
+ assertEqual(merchantRateOrder.status, 201, 'checkout still works after applying a custom rate');
+ const merchantRateItem = merchantRateOrder.data.order.items[0];
+ assertEqual(merchantRateItem.tax_amount, 21, 'a 200-line product is charged 10.5% x 200 = 21 with the merchant rate');
+ const merchantRateSnapshot = typeof merchantRateItem.tax_snapshot === 'string'
+ ? JSON.parse(merchantRateItem.tax_snapshot) : merchantRateItem.tax_snapshot;
+ assertEqual(merchantRateSnapshot.lines[0].categoryId, 'merchant:10.5', 'category id resolves through the merchant-rate path');
+ assertEqual(merchantRateSnapshot.lines[0].components[0].rate, '10.5', 'the rule rate matches the typed value');
+
+ // Clean up the merchant categories + rules so subsequent sections
+ // (charge categories and their assertions) aren't affected.
+ const indiaPackRow = db.prepare(
+ `SELECT active_version_id FROM country_packs WHERE id = 'official-india'`
+ ).get() as { active_version_id: string };
+ db.prepare(
+ `DELETE FROM tax_overrides
+ WHERE pack_version_id = ? AND json_extract(value_json, '$.categoryId') LIKE 'merchant:%'`
+ ).run(indiaPackRow.active_version_id);
+ db.prepare(
+ `DELETE FROM tax_categories WHERE pack_version_id = ? AND category_id LIKE 'merchant:%'`
+ ).run(indiaPackRow.active_version_id);
+ db.prepare(
+ `DELETE FROM tax_rules WHERE pack_version_id = ? AND rule_id LIKE 'merchant:%'`
+ ).run(indiaPackRow.active_version_id);
+
+ // Clean up the store-wide product override so subsequent sections
+ // (which assume a baseline of "no store-wide default") still pass.
+ const storeWideList = await api(baseUrl, '/api/tax-packs', { headers: owner.authHeader });
+ const storeWidePack = storeWideList.data.packs.find((pack: any) => pack.id === 'official-india');
+ const storeWideOverrideRow = db.prepare(
+ `SELECT id FROM tax_overrides
+ WHERE pack_version_id = ? AND entity_type = 'product' AND entity_id IS NULL`
+ ).get(storeWidePack.active_version_id) as { id: string } | undefined;
+ if (storeWideOverrideRow) {
+ await api(baseUrl, `/api/tax-packs/overrides/${storeWideOverrideRow.id}`, {
+ method: 'DELETE',
+ headers: owner.authHeader,
+ });
+ }
+ const productOverrideOnTopRow = db.prepare(
+ `SELECT id FROM tax_overrides
+ WHERE pack_version_id = ? AND entity_type = 'product' AND entity_id = 'priority-product'`
+ ).get(storeWidePack.active_version_id) as { id: string } | undefined;
+ if (productOverrideOnTopRow) {
+ await api(baseUrl, `/api/tax-packs/overrides/${productOverrideOnTopRow.id}`, {
+ method: 'DELETE',
+ headers: owner.authHeader,
+ });
+ }
+
console.log('\n5. Charge categories persist and stay stable across every recompute path');
db.prepare(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('discount_max_percentage', '100', datetime('now'))",