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')}

@@ -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 })}

{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 })}

))} ) : ( -

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 && ( @@ -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}
@@ -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')}

@@ -742,7 +919,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) { {version.status} {active ? ( - Active + {t('common.active')} ) : ( )}
@@ -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')}

)}
setTestBehavior(event.target.value)} className="rounded-md border border-gray-200 px-3 py-2 text-sm disabled:bg-gray-100"> - - - - + + + + - +
{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) => { + +
+ +
+ +
+ {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 ( -