diff --git a/docs/tax-packs.md b/docs/tax-packs.md index f009d403..83a8d4ff 100644 --- a/docs/tax-packs.md +++ b/docs/tax-packs.md @@ -23,7 +23,7 @@ It must not embed scripts, call network endpoints, or introduce a second tax-cal ## Authoring a new pack 1. Copy the shape of the bundled pack — `main/tax-packs/generic.json` — as your starting point, then add real `categories` and `rules` for your country. -2. Give it a unique `id` using the lowercase, hyphenated full country name (for example, `official-india`, `official-thailand`, or `official-united-states`). Keep `country` as its ISO alpha-2 code (for example, `IN`, `TH`, or `US`), because FloCafe uses that field to match the store. Set `publisher` to your name/org (anything other than `local` — `local` is reserved for the in-app manual/unbundled pack and can never be published), and fill in `jurisdiction`, `currency`, `taxRounding`, `payableRounding`. +2. Give it a unique `id` using the lowercase, hyphenated full country name (for example, `official-argentina`, `official-india`, `official-thailand`, or `official-united-states`). Keep `country` as its ISO alpha-2 code (for example, `AR`, `IN`, `TH`, or `US`), because FloCafe uses that field to match the store. Set `publisher` to your name/org (anything other than `local` — `local` is reserved for the in-app manual/unbundled pack and can never be published), and fill in `jurisdiction`, `currency`, `taxRounding`, `payableRounding`. 3. Define `categories` and `rules`. Every category referenced by `defaultCategories` or by a product must exist. `unclassifiedCategoryId` must point at a real category (usually a zero-rate one). A rate table can use multiple regional components (each its own rule) or a single flat rule, depending on how the country's tax works. 4. Add the file to `main/tax-packs/` in this repo (not a new repo — see "Where packs live" below) and open a PR. Only the generic/manual no-tax pack is bundled with and auto-activated by a new installation. Every official country pack is catalog-only: an owner explicitly enables the matching pack from Settings → Tax Configuration, where FloCafe downloads, verifies, installs, and activates it. 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. diff --git a/frontend/src/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index 3765e4f4..dacdc463 100644 --- a/frontend/src/app/(dashboard)/products/page.tsx +++ b/frontend/src/app/(dashboard)/products/page.tsx @@ -100,6 +100,7 @@ export default function ProductsPage() { const [taxCategories, setTaxCategories] = useState<{ id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]>([]); const [defaultTaxCategoryId, setDefaultTaxCategoryId] = useState(''); + const [defaultTaxInclusive, setDefaultTaxInclusive] = useState(null); const [showBulkTaxModal, setShowBulkTaxModal] = useState(false); const [bulkTaxCategoryId, setBulkTaxCategoryId] = useState(''); const [bulkTaxApplying, setBulkTaxApplying] = useState(false); @@ -146,9 +147,10 @@ export default function ProductsPage() { .finally(() => { setLoading(false); }); api.get('/tax/categories', { signal: controller.signal }) .then((res) => { - const data = res.data as { categories?: { id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]; default_category_id?: string | null }; + const data = res.data as { categories?: { id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]; default_category_id?: string | null; default_inclusive?: boolean | null }; setTaxCategories(data.categories || []); setDefaultTaxCategoryId(data.default_category_id || ''); + setDefaultTaxInclusive(data.default_inclusive ?? null); }) .catch((err: unknown) => { if (!(err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError'))) setTaxCategories([]); @@ -582,7 +584,19 @@ export default function ProductsPage() {
- {taxLabel} + + {taxLabel} + {product.tax_behavior === 'inclusive' && ( + + {t('products.taxInclusiveShort')} + + )} + {product.tax_behavior === 'exclusive' && ( + + {t('products.taxExclusiveShort')} + + )} + {!product.tax_category_id && taxCategories.length > 0 && ( {t('products.notTaxedBadge')} @@ -753,6 +767,15 @@ export default function ProductsPage() {

The rate is resolved from the active tax profile for this category, not entered manually.

+ {form.tax_behavior === 'country_default' && defaultTaxInclusive != null ? ( +

+ {t('products.taxBehaviorDefaultHint', { behavior: defaultTaxInclusive ? t('products.taxInclusive') : t('products.taxExclusive') })} +

+ ) : form.tax_behavior === 'inclusive' ? ( +

{t('products.taxInclusiveHint')}

+ ) : form.tax_behavior === 'exclusive' ? ( +

{t('products.taxExclusiveHint')}

+ ) : null}
) : (

diff --git a/frontend/src/components/settings/TaxConfigurationPanel.tsx b/frontend/src/components/settings/TaxConfigurationPanel.tsx index 26679e8a..6f1962f9 100644 --- a/frontend/src/components/settings/TaxConfigurationPanel.tsx +++ b/frontend/src/components/settings/TaxConfigurationPanel.tsx @@ -20,6 +20,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; @@ -221,6 +222,7 @@ function auditDescription(row: AuditRow): string { } export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) { + const { t } = useI18n(); const [packs, setPacks] = useState([]); const [storeCountry, setStoreCountry] = useState(''); const [selectedPackId, setSelectedPackId] = useState(''); @@ -717,6 +719,33 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) { } } + // Worked example for the "Menu prices" radios: show what a product priced + // 100 actually costs the customer at the rate of the default product + // category (falls back to the first category's first percent component). + const exampleRate = useMemo(() => { + const defaultCategory = manualCategories.find((category) => category.tempId === manualDefaults.product) + || manualCategories[0]; + if (!defaultCategory) return null; + const percent = defaultCategory.components.find((component) => component.type === 'percent'); + if (!percent) return null; + const rate = Number(percent.value); + if (!Number.isFinite(rate) || rate <= 0) return null; + return rate; + }, [manualCategories, manualDefaults.product]); + const example = useMemo(() => { + if (exampleRate == null) return null; + if (manualInclusive) { + const tax = 100 - 100 / (1 + exampleRate / 100); + return t('settings.taxConfigExampleInclusive', { rate: exampleRate, tax: tax.toFixed(2) }); + } + const tax = 100 * (exampleRate / 100); + return t('settings.taxConfigExampleExclusive', { + rate: exampleRate, + total: (100 + tax).toFixed(2), + tax: tax.toFixed(2), + }); + }, [exampleRate, manualInclusive, t]); + if (loading && !detail) { return

Loading tax configuration…
; } @@ -819,6 +848,15 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) { no official tax pack for your country yet, or to replace one with your own rates.

+ {taxMode === 'official' && !manualOverrideConfirm && ( +
+ + + {t('settings.taxConfigOfficialPackReplaceWarning', { country: storeCountry })} + +
+ )} +
{manualCategories.map((category) => (
@@ -898,6 +936,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) { Tax-inclusive (already baked into the menu price)
+ {example &&

{example}

}
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 56bbdaa8..1c6f981c 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -402,6 +402,9 @@ "products.tagPlaceholder": "Type a tag and press Enter", "products.taxExclusive": "Exclusive", "products.taxInclusive": "Inclusive", + "products.taxExclusiveHint": "Exclusive: the tax is added on top of the menu price, so the customer pays more than the price shown.", + "products.taxInclusiveHint": "Inclusive: the menu price already contains the tax, so the customer pays exactly the price shown.", + "products.taxBehaviorDefaultHint": "Country default: follows the active tax profile ({behavior}).", "products.taxNone": "No Tax", "products.notTaxedBadge": "Not taxed", "products.notTaxedTooltip": "No tax category assigned — this product charges no tax and prints no tax line.", @@ -781,6 +784,9 @@ "settings.status": "Status", "settings.storeDetails": "Store Details", "settings.taxConfiguration": "Tax Config", + "settings.taxConfigExampleExclusive": "At {rate}%: a product priced 100 costs the customer {total} — {tax} of tax is added at checkout.", + "settings.taxConfigExampleInclusive": "At {rate}%: a product priced 100 still costs the customer 100, of which {tax} is tax.", + "settings.taxConfigOfficialPackReplaceWarning": "An official tax pack is active for {country}. Saving this manual configuration will replace it.", "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 43fe5962..fd102e55 100644 --- a/frontend/src/lib/i18n/es.json +++ b/frontend/src/lib/i18n/es.json @@ -401,6 +401,9 @@ "products.tagPlaceholder": "Escribí una etiqueta y presioná Enter", "products.taxExclusive": "Excluido", "products.taxInclusive": "Incluido", + "products.taxExclusiveHint": "Excluido: el impuesto se agrega sobre el precio del menú, por lo que el cliente paga más que el precio mostrado.", + "products.taxInclusiveHint": "Incluido: el precio del menú ya contiene el impuesto, por lo que el cliente paga exactamente el precio mostrado.", + "products.taxBehaviorDefaultHint": "Predeterminado del país: sigue el perfil fiscal activo ({behavior}).", "products.taxNone": "Sin impuesto", "products.notTaxedBadge": "Sin impuestos", "products.notTaxedTooltip": "No hay ninguna categoría fiscal asignada: este producto no cobra impuestos ni imprime ninguna línea de impuestos.", @@ -780,6 +783,9 @@ "settings.status": "Estado", "settings.storeDetails": "Datos del Negocio", "settings.taxConfiguration": "Config. fiscal", + "settings.taxConfigExampleExclusive": "Al {rate}%: un producto con precio 100 le cuesta al cliente {total} — {tax} de impuestos se agregan al pagar.", + "settings.taxConfigExampleInclusive": "Al {rate}%: un producto con precio 100 le sigue costando 100 al cliente, de los cuales {tax} son impuestos.", + "settings.taxConfigOfficialPackReplaceWarning": "Hay un paquete fiscal oficial activo para {country}. Guardar esta configuración manual lo reemplazará.", "settings.orderNumberFormat": "Formato del número de pedido", "settings.orderNumberPrefix": "Prefijo", "settings.orderNumberPreview": "Vista previa", diff --git a/frontend/src/lib/i18n/fa.json b/frontend/src/lib/i18n/fa.json index 50598125..87b63832 100644 --- a/frontend/src/lib/i18n/fa.json +++ b/frontend/src/lib/i18n/fa.json @@ -402,6 +402,9 @@ "products.tagPlaceholder": "یک برچسب بنویسید و Enter را بزنید", "products.taxExclusive": "بدون مالیات", "products.taxInclusive": "همراه مالیات", + "products.taxExclusiveHint": "بدون مالیات: مالیات به قیمت منو اضافه می‌شود، بنابراین مشتری بیش از قیمت نمایش‌داده‌شده پرداخت می‌کند.", + "products.taxInclusiveHint": "همراه مالیات: قیمت منو از قبل شامل مالیات است، بنابراین مشتری دقیقاً همان قیمت نمایش‌داده‌شده را پرداخت می‌کند.", + "products.taxBehaviorDefaultHint": "پیش‌فرض کشور: از نمایه مالیاتی فعال پیروی می‌کند ({behavior}).", "products.taxNone": "بدون مالیات", "products.notTaxedBadge": "مشمول مالیات نیست", "products.notTaxedTooltip": "هیچ دسته مالیاتی برای این کالا برگزیده نشده است — این کالا مالیاتی ندارد و ردیف مالیات در رسید چاپ نمی‌شود.", @@ -781,6 +784,9 @@ "settings.status": "Status", "settings.storeDetails": "Store Details", "settings.taxConfiguration": "Tax Config", + "settings.taxConfigExampleExclusive": "با نرخ {rate}٪: کالایی با قیمت ۱۰۰ برای مشتری {total} هزینه دارد — هنگام تسویه {tax} مالیات اضافه می‌شود.", + "settings.taxConfigExampleInclusive": "با نرخ {rate}٪: کالایی با قیمت ۱۰۰ همچنان برای مشتری ۱۰۰ هزینه دارد که {tax} از آن مالیات است.", + "settings.taxConfigOfficialPackReplaceWarning": "یک بسته مالیاتی رسمی برای {country} فعال است. ذخیره این پیکربندی دستی جایگزین آن خواهد شد.", "settings.orderNumberFormat": "Order Number Format", "settings.orderNumberPrefix": "Prefix", "settings.orderNumberPreview": "Preview", diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json index b5a81ce1..dc5dc59a 100644 --- a/frontend/src/lib/i18n/pt.json +++ b/frontend/src/lib/i18n/pt.json @@ -401,6 +401,9 @@ "products.tagPlaceholder": "Digite uma etiqueta e pressione Enter", "products.taxExclusive": "Exclusivo", "products.taxInclusive": "Inclusivo", + "products.taxExclusiveHint": "Exclusivo: o imposto é adicionado sobre o preço do menu, então o cliente paga mais que o preço mostrado.", + "products.taxInclusiveHint": "Inclusivo: o preço do menu já contém o imposto, então o cliente paga exatamente o preço mostrado.", + "products.taxBehaviorDefaultHint": "Padrão do país: segue o perfil fiscal ativo ({behavior}).", "products.taxNone": "Sem Imposto", "products.notTaxedBadge": "Sem imposto", "products.notTaxedTooltip": "Nenhuma categoria de imposto atribuída — este produto não cobra imposto nem imprime uma linha de imposto.", @@ -780,6 +783,9 @@ "settings.status": "Status", "settings.storeDetails": "Detalhes da Loja", "settings.taxConfiguration": "Config. tributária", + "settings.taxConfigExampleExclusive": "A {rate}%: um produto com preço 100 custa ao cliente {total} — {tax} de impostos são adicionados no checkout.", + "settings.taxConfigExampleInclusive": "A {rate}%: um produto com preço 100 ainda custa 100 ao cliente, dos quais {tax} são impostos.", + "settings.taxConfigOfficialPackReplaceWarning": "Há um pacote fiscal oficial ativo para {country}. Salvar esta configuração manual o substituirá.", "settings.orderNumberFormat": "Formato do Número do Pedido", "settings.orderNumberPrefix": "Prefixo", "settings.orderNumberPreview": "Pré-visualização", diff --git a/main/routes/index.ts b/main/routes/index.ts index 72ec0d02..83d69733 100644 --- a/main/routes/index.ts +++ b/main/routes/index.ts @@ -132,6 +132,11 @@ export function registerRoutes(app: Express): void { }) : [], default_category_id: configurationReady ? pack.defaultCategories.product : null, + // Whether the active pack treats menu prices as already containing + // tax. The products page uses it to explain what a product's + // "Country default" tax behavior resolves to, instead of leaving + // the label opaque. + default_inclusive: configurationReady ? pack.inclusivePricingDefault : null, configuration_ready: configurationReady, unclassified_category_id: pack.unclassifiedCategoryId, }); diff --git a/main/tax-packs/argentina.json b/main/tax-packs/argentina.json new file mode 100644 index 00000000..a6b96371 --- /dev/null +++ b/main/tax-packs/argentina.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "id": "official-argentina", + "publisher": "official-argentina", + "version": "0.1.0", + "country": "AR", + "jurisdiction": "*", + "currency": "ARS", + "effectiveFrom": "2026-08-07", + "publishedAt": "2026-08-07", + "minFloVersion": "2.4.0", + "taxPoint": "finalized_at", + "inclusivePricingDefault": true, + "registrationNumberLabel": "CUIT", + "categories": [ + { "id": "iva_21", "label": "IVA 21%", "ruleIds": ["iva-21"] }, + { "id": "iva_105", "label": "IVA 10.5%", "ruleIds": ["iva-105"] }, + { "id": "iva_27", "label": "IVA 27%", "ruleIds": ["iva-27"] }, + { "id": "packaging", "label": "Packaging", "ruleIds": [], "defaultBehavior": "exempt" }, + { "id": "delivery", "label": "Delivery", "ruleIds": [], "defaultBehavior": "exempt" }, + { "id": "service_charge", "label": "Service charge", "ruleIds": [], "defaultBehavior": "exempt" }, + { "id": "addon", "label": "Add-on", "ruleIds": [], "defaultBehavior": "exempt" }, + { "id": "iva_exempt", "label": "Exento", "ruleIds": [], "defaultBehavior": "exempt" } + ], + "defaultCategories": { + "product": "iva_21", + "packaging": "packaging", + "delivery": "delivery", + "service_charge": "service_charge", + "addon": "addon" + }, + "unclassifiedCategoryId": "iva_exempt", + "rules": [ + { + "id": "iva-21", + "label": "IVA 21%", + "type": "percent", + "categoryIds": ["iva_21"], + "rate": "21" + }, + { + "id": "iva-105", + "label": "IVA 10.5%", + "type": "percent", + "categoryIds": ["iva_105"], + "rate": "10.5" + }, + { + "id": "iva-27", + "label": "IVA 27%", + "type": "percent", + "categoryIds": ["iva_27"], + "rate": "27" + } + ], + "taxRounding": { + "scope": "line", + "method": "half_up", + "decimalPlaces": 2, + "remainderAllocation": "largest_remainder" + }, + "payableRounding": { + "increment": "0.01", + "method": "half_up" + } +} diff --git a/tests/e2e-argentina-flow.test.ts b/tests/e2e-argentina-flow.test.ts index 9a8ad8e5..52511fea 100644 --- a/tests/e2e-argentina-flow.test.ts +++ b/tests/e2e-argentina-flow.test.ts @@ -30,7 +30,9 @@ Module._load = function (request, parent, isMain) { const { initTestDb, createApp, startServer, - seedOwnerUser, api, assert, assertEqual, assertIncludes, + seedOwnerUser, seedCategory, seedProduct, + api, assert, assertEqual, assertIncludes, + installAndActivateTestTaxPack, getResults, closeDatabase, } = require('./helpers/test-setup'); @@ -186,15 +188,37 @@ async function runArgentinaTaxAndCustomers(baseUrl, db) { const settingsTax = db.prepare("SELECT value FROM settings WHERE key = 'country'").get(); assertEqual(settingsTax.value, 'AR', 'settings.country = AR after first-run setup'); + // Install the Argentina IVA pack so the assertion below validates the + // production tax behaviour (an uncategorised product with the active + // Argentina pack resolves to the pack's default product category and + // computes the IVA rate). Installing the pack here makes this test + // future-proof: it no longer silently depends on the Argentina pack + // being absent. + const argentinaPackData = require('../main/tax-packs/argentina.json'); + installAndActivateTestTaxPack(db, argentinaPackData); + // Seed a real DB product (calculateItemTax reads tax_category_id from the + // row, not from a literal object). The category is set to the pack's + // default product category, mirroring what ensure-country's backfill + // would do after activating a pack. + seedCategory(db, 'cat-ar-test', 'Argentina Test'); + seedProduct(db, 'prod-ar-test', 'cat-ar-test', 'Argentina Test Item', 100, { + tax_type: 'inclusive', + tax_category_id: 'iva_21', + }); + const product = db.prepare(`SELECT * FROM products WHERE id = 'prod-ar-test'`).get(); + const { calculateItemTax } = require('../main/services/tax'); const result = calculateItemTax( { country: 'AR', business_type: 'restaurant', state_code: '', taxes_enabled: true }, - { tax_type: 'inclusive', tax_rate: 21 }, + product, 100, null, ); - assertEqual(result.tax_amount, 0, 'uncategorized AR product is tax-free'); - assertEqual(result.tax_breakdown.length, 0, 'uncategorized AR product emits no tax breakdown'); + assertEqual(result.tax_type, 'inclusive', 'uncategorized AR product inherits the pack-inclusive pricing default'); + assertEqual(result.tax_amount, 17.36, 'ARS 100 inclusive at 21% extracts ARS 17.36 tax'); + assertEqual(result.tax_breakdown.length, 1, 'IVA pack emits one tax component for the default category'); + assertEqual(result.tax_breakdown[0].title, 'IVA 21%', 'tax component is labelled with the Argentina pack rule'); + assertEqual(result.tax_breakdown[0].rate, 21, 'tax component rate matches the IVA 21% rule'); const cRes = await api(baseUrl + '/api', '/customers', { method: 'POST', diff --git a/tests/tax-pack-management.test.ts b/tests/tax-pack-management.test.ts index 252711dc..e1a868c6 100644 --- a/tests/tax-pack-management.test.ts +++ b/tests/tax-pack-management.test.ts @@ -612,6 +612,113 @@ async function main() { 0, 'failed validation leaves no installed version behind', ); + + console.log('\n9. Argentina IVA pack passes activation validation and computes inclusive tax'); + const argentinaPackData = require('../main/tax-packs/argentina.json'); + const argentinaPackJson = JSON.stringify(argentinaPackData); + const argentinaSignature = sign( + null, + Buffer.from(argentinaPackJson, 'utf8'), + privateKey, + ).toString('base64'); + const argentinaTag = `tax-pack-${argentinaPackData.id}-v${argentinaPackData.version}`; + const argentinaEntry = { + id: argentinaPackData.id, + publisher: argentinaPackData.publisher, + country: argentinaPackData.country, + jurisdiction: argentinaPackData.jurisdiction, + version: argentinaPackData.version, + publishedAt: argentinaPackData.publishedAt, + minFloVersion: argentinaPackData.minFloVersion, + downloadUrl: `https://github.com/FreeOpenSourcePOS/FloCafe-Plugins/releases/download/${argentinaTag}/${argentinaPackData.id}-v${argentinaPackData.version}.json`, + signatureUrl: `https://github.com/FreeOpenSourcePOS/FloCafe-Plugins/releases/download/${argentinaTag}/${argentinaPackData.id}-v${argentinaPackData.version}.json.sig`, + digest: taxPackSha256(argentinaPackJson), + }; + const argentinaFetch = async (input: string | URL | Request) => new Response( + String(input) === argentinaEntry.downloadUrl ? argentinaPackJson : argentinaSignature, + { status: 200 }, + ); + const argentinaInstalled = await installCatalogEntry(argentinaEntry, { + actorUserId: owner.userId, + fetchImpl: argentinaFetch, + publicKey, + }); + assertEqual( + argentinaInstalled.validation.checks.length, + 24, + 'Argentina pack goes through the same 24-check validation as every other country pack', + ); + assertEqual( + argentinaInstalled.validation.valid, + true, + 'Argentina IVA pack passes activation validation', + ); + + // Schema sanity: the Argentina pack source JSON declares + // registrationNumberLabel so receipt/footer consumers resolve the label + // through getActiveCountryPack as through countries.ts. + assertEqual(argentinaPackData.registrationNumberLabel, 'CUIT', 'Argentina pack declares registration label "CUIT"'); + + // The store country must match the pack for getActiveCountryPack to pick + // it up; the other sections set it to IN/TH through the legacy fixtures. + db.prepare("UPDATE settings SET value = 'AR' WHERE key = 'country'").run(); + db.prepare("UPDATE settings SET value = 'true' WHERE key = 'taxes_enabled'").run(); + + // Activate through the real owner route (POST /:packId/versions/:versionId/activate) + // so this section exercises the production activation path: demoting the + // previous active pack for AR, re-pointing tax_overrides, and writing the + // activate_pack audit row. The route re-runs validationChecklist with the + // app's trusted signing key; the pack above was signed with the test + // keypair, so point the trusted-key binding at that same keypair first + // (ts-node emits CommonJS, so the import resolves as a live property access). + const trustedKeyModule = require('../main/tax-packs/trusted-signing-key'); + trustedKeyModule.TRUSTED_TAX_PACK_SIGNING_PUBLIC_KEY = publicKey; + const activateRes = await api( + baseUrl, + `/api/tax-packs/${argentinaPackData.id}/versions/${argentinaInstalled.versionId}/activate`, + { method: 'POST', headers: owner.authHeader }, + ); + assertEqual(activateRes.status, 200, 'activation route accepts the catalog-installed Argentina pack'); + assertEqual( + activateRes.data.active_version_id, + argentinaInstalled.versionId, + 'activation route activates the installed version', + ); + assertEqual( + db.prepare( + `SELECT COUNT(*) AS count FROM tax_config_audit WHERE action = 'activate_pack' AND pack_id = ?` + ).get(argentinaPackData.id).count, + 1, + 'route-based activation is audited', + ); + + // Mirror ensure-country's category backfill (main/routes/tax-packs.ts:739-744) + // so the active pack's default product category is what uncategorized + // products resolve to. The activation route deliberately leaves product + // data alone; ensure-country performs the backfill in production. + db.prepare( + `UPDATE products SET tax_category_id = ? WHERE tax_category_id IS NULL AND deleted_at IS NULL` + ).run(argentinaPackData.defaultCategories.product); + db.prepare( + `UPDATE addons SET tax_category_id = ? WHERE tax_category_id IS NULL` + ).run(argentinaPackData.defaultCategories.addon); + + const arCalculation = await api(baseUrl, '/api/tax-packs/test-calculation', { + method: 'POST', + body: { category_id: 'iva_21', amount: '1000', tax_behavior: 'inclusive' }, + headers: manager.authHeader, + }); + assertEqual(arCalculation.status, 200, 'Argentina test calculation runs against the active pack'); + assertEqual( + arCalculation.data.calculation.taxAmount, + '173.55', + 'ARS 1000 inclusive at 21% extracts ARS 173.55 tax', + ); + assertEqual( + arCalculation.data.calculation.payableTotal, + '1000', + 'inclusive payable total stays at ARS 1000', + ); } finally { server.close(); closeDatabase();