Skip to content

Commit e56310c

Browse files
committed
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 added/extended: - 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 the unsigned IN/TH seeds). - tests/integration-tax.test.ts: AR exclusive order at ARS 1000 produces 210 IVA and 1210 total; 20% discount recomputes to 168 IVA and 968 total; inclusive product keeps IVA inside the displayed price and persists the behavior as inclusive in the snapshot; the bill copies the order tax. Every breakdown is asserted to contain no IIBB or 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). Companion e2e-server fix (unblocks CI for this PR): Commit 3a75876 ('publish country packs separately') moved India and Thailand from BUNDLED_COUNTRY_PACKS to catalog-only delivery, and the e2e-server fixture 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 the 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'. This is the same regression silently failing the most recent two main CI runs before this PR. Fix by inlining the same install-tax-pack SQL helper that the unit and integration tests already use. Only Thailand is installed — it's the only country-specific pack exercised by e2e specs. Verified locally: npm run lint, npm run build, npm run test:tax-engine (12/12), npm test (0 failed across every suite including Integration Tax 114/114 and Tax Pack Management 103/103), git diff --check clean. Publishing (tag, push to upstream, CI sign) is not part of this PR. That step stays gated on maintainer review per the task spec; no signing secret was touched.
1 parent 3a75876 commit e56310c

6 files changed

Lines changed: 296 additions & 1 deletion

File tree

docs/tax-packs.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ It must not embed scripts, call network endpoints, or introduce a second tax-cal
2929
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.
3030
6. Run `npm run test:tax-engine` and the full `npm test` before opening the PR.
3131

32+
## Country pack scopes
33+
34+
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.
35+
36+
### Argentina (`official-argentina`)
37+
38+
- **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.
39+
- **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.
40+
- **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.
41+
- **Intentional unsupported (do not add to this pack without a scope bump):**
42+
- **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.
43+
- **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.
44+
- **Incremented-rate IVA (27%).** Applies only to non-residential metered utilities (gas, electricity, water, telecoms to commercial premises). Out of scope for restaurant supplies.
45+
- **2.5% super-reduced (printed newspapers/magazines)** and **0% (exports).** Not applicable to domestic restaurant sales.
46+
- **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*.
47+
- **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.
48+
- **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.
49+
- **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.
50+
3251
## Where packs live, how they get signed, and how they're published
3352

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

main/tax-packs/argentina.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"schemaVersion": 1,
3+
"id": "official-argentina",
4+
"publisher": "FreeOpenSourcePOS",
5+
"version": "1.0.0",
6+
"country": "AR",
7+
"jurisdiction": "*",
8+
"currency": "ARS",
9+
"effectiveFrom": "2026-01-01",
10+
"publishedAt": "2026-01-01",
11+
"minFloVersion": "2.4.0",
12+
"taxPoint": "finalized_at",
13+
"inclusivePricingDefault": true,
14+
"registrationNumberLabel": "CUIT",
15+
"categories": [
16+
{ "id": "standard", "label": "Standard", "ruleIds": ["iva"] },
17+
{ "id": "packaging", "label": "Packaging", "ruleIds": ["iva"] },
18+
{ "id": "delivery", "label": "Delivery", "ruleIds": ["iva"] },
19+
{ "id": "service_charge", "label": "Service charge", "ruleIds": ["iva"] },
20+
{ "id": "addon", "label": "Add-on", "ruleIds": ["iva"] },
21+
{ "id": "unclassified", "label": "Unclassified", "ruleIds": ["iva"] }
22+
],
23+
"defaultCategories": {
24+
"product": "standard",
25+
"packaging": "packaging",
26+
"delivery": "delivery",
27+
"service_charge": "service_charge",
28+
"addon": "addon"
29+
},
30+
"unclassifiedCategoryId": "unclassified",
31+
"rules": [
32+
{
33+
"id": "iva",
34+
"label": "IVA",
35+
"type": "percent",
36+
"categoryIds": ["standard", "packaging", "delivery", "service_charge", "addon", "unclassified"],
37+
"rate": "21"
38+
}
39+
],
40+
"taxRounding": {
41+
"scope": "line",
42+
"method": "half_up",
43+
"decimalPlaces": 2,
44+
"remainderAllocation": "largest_remainder"
45+
},
46+
"payableRounding": {
47+
"increment": "0.01",
48+
"method": "half_up"
49+
}
50+
}

tests/e2e-server.cjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Module._load = function (request, parent, isMain) {
1616
};
1717

1818
const bcrypt = require('bcryptjs');
19+
const crypto = require('crypto');
1920
const { initDatabase, getDatabase, closeDatabase, now } = require('../dist/db');
2021
const { startServer, stopServer } = require('../dist/server');
2122
const { startKdsServer, stopKdsServer } = require('../dist/kds-server');
@@ -26,6 +27,79 @@ function seedUser(id, email, role) {
2627
).run(id, `E2E ${role}`, email, bcrypt.hashSync('E2ePass123!', 10), role, now(), now());
2728
}
2829

30+
// After commit 3a75876 ("publish country packs separately"), only the generic
31+
// pack auto-installs on first-run; country packs come from the signed catalog
32+
// after an owner opt-in. The e2e fixture seeds country=TH but never goes
33+
// through that owner action, so it has no active TH pack and the checkout
34+
// throws "no tax rules apply". Mirror the production owner-install action by
35+
// registering the TH pack directly. This is the same SQL helper that the
36+
// unit/integration tests use, just inlined here because e2e-server runs from
37+
// compiled dist/ and cannot import the .ts test helpers.
38+
function installTaxPackFixture(pack) {
39+
const db = getDatabase();
40+
const installedAt = now();
41+
const versionId = `${pack.id}@${pack.version}`;
42+
const packJson = JSON.stringify(pack);
43+
const digest = crypto.createHash('sha256').update(packJson).digest('hex');
44+
45+
db.transaction(() => {
46+
db.prepare(`
47+
INSERT INTO country_packs (
48+
id, publisher, country, jurisdiction, active_version_id, status, created_at, updated_at
49+
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
50+
ON CONFLICT(id) DO UPDATE SET
51+
publisher = excluded.publisher,
52+
country = excluded.country,
53+
jurisdiction = excluded.jurisdiction,
54+
active_version_id = excluded.active_version_id,
55+
status = 'active',
56+
updated_at = excluded.updated_at
57+
`).run(pack.id, pack.publisher, pack.country, pack.jurisdiction, versionId, installedAt, installedAt);
58+
59+
db.prepare(`
60+
INSERT OR REPLACE INTO country_pack_versions (
61+
id, pack_id, version, schema_version, manifest_json, pack_json, digest, signature,
62+
effective_from, effective_to, min_flo_version, published_at, status, created_at
63+
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 'active', ?)
64+
`).run(
65+
versionId, pack.id, pack.version, pack.schemaVersion,
66+
JSON.stringify({
67+
id: pack.id, publisher: pack.publisher, country: pack.country,
68+
jurisdiction: pack.jurisdiction, version: pack.version, publishedAt: pack.publishedAt,
69+
}),
70+
packJson, digest,
71+
pack.effectiveFrom, pack.effectiveTo || null, pack.minFloVersion, pack.publishedAt,
72+
installedAt,
73+
);
74+
75+
const insertCategory = db.prepare(`
76+
INSERT OR REPLACE INTO tax_categories (
77+
id, pack_version_id, category_id, label, default_behavior, definition_json, created_at
78+
) VALUES (?, ?, ?, ?, ?, ?, ?)
79+
`);
80+
for (const category of pack.categories) {
81+
insertCategory.run(
82+
`${versionId}:category:${category.id}`, versionId, category.id, category.label,
83+
category.defaultBehavior || null, JSON.stringify(category), installedAt,
84+
);
85+
}
86+
87+
const insertRule = db.prepare(`
88+
INSERT OR REPLACE INTO tax_rules (
89+
id, pack_version_id, rule_id, label, calculation_type, rate, amount,
90+
applies_per, base_rule_ids, definition_json, created_at
91+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
92+
`);
93+
for (const rule of pack.rules) {
94+
insertRule.run(
95+
`${versionId}:rule:${rule.id}`, versionId, rule.id, rule.label, rule.type,
96+
rule.rate || null, rule.amount || null, rule.appliesPer || null,
97+
JSON.stringify(rule.baseRuleIds || []), JSON.stringify(rule), installedAt,
98+
);
99+
}
100+
})();
101+
}
102+
29103
function seedPosFixture() {
30104
const db = getDatabase();
31105
const createdAt = now();
@@ -82,6 +156,7 @@ function stop(exitCode = 0) {
82156
initDatabase();
83157
seedUser('e2e-manager', 'manager@flo.local', 'manager');
84158
seedPosFixture();
159+
installTaxPackFixture(require('../main/tax-packs/th.json'));
85160
await startServer();
86161
await startKdsServer();
87162
console.log('[E2E] Main and KDS servers ready');

tests/integration-tax.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const { billRoutes } = require('../main/routes/bills');
3333
const { registerRoutes } = require('../main/routes/index');
3434
const indiaTaxPack = require('../main/tax-packs/in.json');
3535
const thailandTaxPack = require('../main/tax-packs/th.json');
36+
const argentinaTaxPack = require('../main/tax-packs/argentina.json');
3637

3738
async function main() {
3839
console.log('Integration Test: Tax Correctness');
@@ -46,6 +47,7 @@ async function main() {
4647
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('state_code', '27', ?)").run(now());
4748
installAndActivateTestTaxPack(db, indiaTaxPack);
4849
installAndActivateTestTaxPack(db, thailandTaxPack);
50+
installAndActivateTestTaxPack(db, argentinaTaxPack);
4951

5052
// Seed data
5153
const { authHeader } = seedOwnerUser(db);
@@ -509,6 +511,88 @@ async function main() {
509511
.run(activeThailandVersion.pack_json, activeThailandVersion.id);
510512
db.prepare("UPDATE settings SET value = 'IN' WHERE key = 'country'").run();
511513

514+
// ── Step 11: Argentina standard 21% IVA — exclusive + inclusive + discount — no IIBB ──
515+
console.log('\n11. Argentina standard 21% IVA produces no IIBB component and discounts scale correctly');
516+
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'AR', ?)").run(now());
517+
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_type', 'restaurant', ?)").run(now());
518+
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('state_code', '02', ?)").run(now());
519+
520+
seedProduct(db, 'prod-tax-ar-1', 'cat-tax', 'Argentina Cafe', 1000, {
521+
tax_category_id: 'standard',
522+
tax_behavior: 'exclusive',
523+
});
524+
seedProduct(db, 'prod-tax-ar-2', 'cat-tax', 'Argentina Cafe Inclusive', 1210, {
525+
tax_category_id: 'standard',
526+
tax_behavior: 'inclusive',
527+
});
528+
529+
const arExclusiveRes = await api(baseUrl, '/api/orders', {
530+
method: 'POST',
531+
body: {
532+
type: 'takeaway',
533+
items: [{ product_id: 'prod-tax-ar-1', quantity: 1 }],
534+
},
535+
headers: authHeader,
536+
});
537+
assertEqual(arExclusiveRes.status, 201, 'Argentina exclusive order created');
538+
assertEqual(arExclusiveRes.data.order.subtotal, 1000, 'AR exclusive subtotal = ARS 1000');
539+
assertEqual(arExclusiveRes.data.order.tax_amount, 210, 'AR exclusive tax = ARS 210 (21% of ARS 1000)');
540+
assertEqual(arExclusiveRes.data.order.total, 1210, 'AR exclusive total = ARS 1210 (1000 + 210)');
541+
const arRawBreakdown = arExclusiveRes.data.order.tax_breakdown;
542+
const arParsed = typeof arRawBreakdown === 'string' ? JSON.parse(arRawBreakdown) : arRawBreakdown;
543+
const arEntries = Array.isArray(arParsed[0]) ? arParsed.flat() : arParsed;
544+
const arIva = arEntries.find((b: any) => b.title === 'IVA');
545+
assert(!!arIva, 'Argentina breakdown contains an IVA entry');
546+
assert(
547+
arEntries.every((b: any) => !/IIBB|ingresos/i.test(b.title)),
548+
'Argentina breakdown must NOT contain an IIBB/Ingresos Brutos component (provincial, out of scope)',
549+
);
550+
assertEqual(arIva.rate, 21, 'Argentina IVA component rate is 21');
551+
552+
// Discount recompute: 20% off ARS 1000 = ARS 800 base; 21% = ARS 168 tax; total ARS 968.
553+
const arDiscountRes = await api(baseUrl, `/api/orders/${arExclusiveRes.data.order.id}/discount`, {
554+
method: 'PATCH',
555+
body: { discount_type: 'percentage', discount_value: 20 },
556+
headers: authHeader,
557+
});
558+
assertEqual(arDiscountRes.status, 200, 'AR discount applied');
559+
assertEqual(arDiscountRes.data.order.discount_amount, 200, 'AR discount = ARS 200 (20% of 1000)');
560+
assertEqual(arDiscountRes.data.order.tax_amount, 168, 'AR discounted tax = ARS 168 (21% of 800)');
561+
assertEqual(arDiscountRes.data.order.total, 968, 'AR discounted total = ARS 968');
562+
563+
const arInclusiveRes = await api(baseUrl, '/api/orders', {
564+
method: 'POST',
565+
body: {
566+
type: 'takeaway',
567+
items: [{ product_id: 'prod-tax-ar-2', quantity: 1 }],
568+
},
569+
headers: authHeader,
570+
});
571+
assertEqual(arInclusiveRes.status, 201, 'AR inclusive order created');
572+
assertEqual(arInclusiveRes.data.order.tax_amount, 210, 'AR inclusive IVA = ARS 210 (tax inside the displayed price, net of 1000)');
573+
assertEqual(arInclusiveRes.data.order.total, 1210, 'AR inclusive total = ARS 1210 (IVA not added again)');
574+
assert(!!arInclusiveRes.data.order.items[0].tax_snapshot, 'AR inclusive item carries a tax_snapshot');
575+
assertEqual(arInclusiveRes.data.order.items[0].tax_type, 'inclusive', 'AR inclusive behavior persists as inclusive');
576+
577+
const arBillRes = await api(baseUrl, '/api/bills/generate', {
578+
method: 'POST',
579+
body: { order_id: arExclusiveRes.data.order.id },
580+
headers: authHeader,
581+
});
582+
assertEqual(arBillRes.status, 201, 'AR bill generated from exclusive order');
583+
assertEqual(arBillRes.data.bill.tax_amount, 168, 'AR bill copies discounted IVA = ARS 168');
584+
assertEqual(arBillRes.data.bill.total, 968, 'AR bill total matches discounted order');
585+
const arBillParsed = typeof arBillRes.data.bill.tax_breakdown === 'string'
586+
? JSON.parse(arBillRes.data.bill.tax_breakdown)
587+
: arBillRes.data.bill.tax_breakdown;
588+
const arBillEntries = Array.isArray(arBillParsed[0]) ? arBillParsed.flat() : arBillParsed;
589+
assert(
590+
arBillEntries.every((b: any) => !/IIBB|ingresos/i.test(b.title)),
591+
'AR bill breakdown contains no IIBB component',
592+
);
593+
594+
db.prepare("UPDATE settings SET value = 'IN' WHERE key = 'country'").run();
595+
512596
} finally {
513597
server.close();
514598
closeDatabase();

0 commit comments

Comments
 (0)