feat(tax): Argentina IVA pack + manual-config for unsupported countries - #219
feat(tax): Argentina IVA pack + manual-config for unsupported countries#219carvalab wants to merge 1 commit into
Conversation
The spec's Decision F (docs/tax-engine-v2-spec.md:541) said manual
configuration would be a synthetic local country pack; the PR adds the
pack, then the UI, then makes the UI always reachable and auditable.
Argentina pack
- main/tax-packs/argentina.json: id official-argentina, country AR,
currency ARS, registrationNumberLabel CUIT. Three IVA rate
categories (iva_21, iva_105, iva_27) plus line-kind defaults and
iva_exempt unclassified. Effective 2026-08-07, minFloVersion 2.4.0.
- Not bundled — ships via the signed catalog flow
(tax-pack-official-argentina-v0.1.0 → FloCafe-Plugins release).
- docs/tax-packs.md: added to the official-pack examples list.
Manual-config UI
- POST /api/tax-packs/manual-config (owner-only) builds a synthetic
local pack (publisher 'local', signature null) with one tax percent
rule applied to all six categories. Runs the same 24-check
validationChecklist every other pack runs. Re-save creates a new
version, demotes the previous, and re-points tax_overrides so
merchant overrides carry across.
- TaxConfigurationPanel: the manual rate form is now always visible,
not only after a 404. Shows rate, menu prices (inclusive / exclusive)
with a plain-language explainer and a worked example recomputed live
at the entered rate, registration label with hint, and an amber
warning when an official pack is active. Button copy switches to
"Replace with manual rate" with a window.confirm that names the
pack before sending override: true.
- The form reloads the saved rate / inclusive / label on mount, so a
blank form cannot silently overwrite an existing configuration.
- Every label, toast, audit line, and support-ticket payload resolves
through t(); the panel is fully translated in en/es/pt (1769 keys).
Pack replacement (override)
- manual-config accepts override: true. Without it an active official
pack still 409s (with can_override: true so the panel knows to ask).
- Merchant overrides are left dormant, not re-pointed: a manual pack
applies one rate to every category, so re-pointing changes no tax
outcome but overwrites the categoryId the owner chose — data loss
for zero benefit. Dormant overrides keep the original value and
revive if the official pack is activated again. The count is
surfaced in the response, the audit, and a warning toast.
- replacedPackId and dormantOverrides are written to the
activate_pack audit details. auditDescription renders them:
"Manual configuration (rate 21%, inclusive) · replaced ar-iva;
3 merchant overrides stopped applying".
Category remap (audited)
- backfillCategoryDefaults (shared by manual-config and
ensure-country) is the only thing that prevents checkout from
400-ing on stale tax_category_id values when the incoming pack
does not define the row's category.
- Decision K required that the mapping be recorded. The function
now returns {entity, from, to, count}[] and both call sites write
a remap_categories audit row with the full mapping. The mapping
is also returned in the response so the panel can warn the owner
(⚠ N product/add-on tax categories were remapped and M merchant
overrides stopped applying. Check the audit history.). NULL rows
remain a backfill, not a loss, and are not reported.
Country label fix
- local-generic carries country: '*'. resolveTaxIdLabel was returning
its "Tax registration" label to every country, which leaked to TH.
Now trusts a pack's label only when its country matches the
requested country; otherwise falls through to countries.ts, then
"Tax ID".
One-POS-is-one-country
- The country check on the manual-pack prefill is gone. The real
discriminator was never the country: the bundled no-tax pack is
publisher 'local' too. A "has a rate" test (pack.rules[].rate)
tells a configured manual pack apart from a pristine install. A
new test asserts both halves of that invariant.
Helpers
- main/services/tax.ts: resolveTaxIdLabel (country-scoped),
backfillCategoryDefaults → CategoryRemap[].
- main/routes/tax-packs.ts: manual-config + override, ensure-country,
remap_categories audit on both.
Tests
- tax-pack-management 172/172 (sections 9-13 + invariant):
* section 9: signs Argentina pack via Ed25519, asserts 24-check
validation, ARS 173.55 inclusive extraction on ARS 1000.
* section 10: saves a manual pack (rate 21, CUIT), audit +
backfill + tax calc.
* section 11: rejects manual-config when an official pack is
active; with override: true it replaces, demotes the official
one, remaps stale categories, writes a remap_categories audit,
reports remapped + dormant_overrides in the response.
* section 12: re-save path. Same pack_id, fresh patch version,
previous version demoted, audit previousVersionId set.
* section 13: resolveTaxIdLabel fallback paths.
* invariant: local-generic has no rules, a saved manual-<cc>
has exactly one — the two halves of the panel's prefill test.
- e2e-argentina-flow 35/35: real DB product, ARS 17.36 inclusive at
21%, "IVA 21%" component label.
- integration-tax 94/94, integration-happy-path 24/24, tax-engine
11/11, upgrade-path.
- Both new tests wired into npm test via package.json.
Verification
- npm run build (backend) ✓, npm run build:frontend ✓
- npm run lint ✓ (0 errors)
- npx tsc --noEmit ✓
Out of scope (open spec items)
- Spec activation rule "show a clear before/after summary and
effective date" before activating. The window.confirm names the
pack; old rate → new rate and effective date are not shown yet.
Add when an owner asks for a pre-flight review.
- taxPoint: 'finalized_at' is hardcoded on the synthetic local pack.
Decision I makes held orders get silently repriced at checkout.
order_created is the conservative default; switch when the
held-order stale-warning banner ships (tax-engine-v2-spec.md:646).
Co-Authored-By: Claude <noreply@anthropic.com>
1405e7f to
d462626
Compare
|
@itsbkm @khaira777 The manual tax is for countries without tax country, as MX, (maybe we need send telemetry of this setting to create a PR for that country with that values for the next customer) |
|
Thanks for the work here - the overall direction is great. The versioning, audit trail, dormant overrides, category remapping, registration-label fix, and inclusive/exclusive examples are valuable foundations. Before resuming work, could you coordinate with @itsbkm on the implementation direction? This PR overlaps with the newer manual-tax builder and tax-ordering work already on Once aligned, please rebase this PR onto current |


What this does
This PR adds the Argentina tax pack and the manual-rate UI from the spec (Decision F at
docs/tax-engine-v2-spec.md:541). It then makes that UI always reachable, and writes an audit record for every save.The Argentina pack
A new file,
main/tax-packs/argentina.json, defines the official Argentina pack. It has three IVA rate categories (21%, 10.5%, 27%), a CUIT registration label, and the usual line-kind defaults. It is effective 2026-08-07 and needs FloCafe 2.4.0 or later. Owners install it through the existing signed catalog flow once it is published to theFloCafe-Pluginsrelease; it is not bundled into the app.docs/tax-packs.mdlists it under the official-pack examples.The manual-rate UI
Owners who do not have an official pack for their country can set a single rate themselves.
POST /api/tax-packs/manual-configbuilds a synthetic local pack with onetaxpercent rule that covers every category. The same 24-check validation runs on it that runs on every other pack. Saving again makes a new version, demotes the previous, and re-points merchant overrides so they keep working.The form is always visible now, not only after a 404. It has three fields: rate, menu prices (with a plain-language explainer of what "inclusive" and "exclusive" actually mean, plus a worked example at the rate the owner typed), and registration label. When an official pack is active, the form shows an amber warning, the save button changes to "Replace with manual rate", and the panel asks for confirmation before sending
override: true.The form also reloads the saved rate, inclusive flag, and label when the page loads, so an empty form cannot silently overwrite a working configuration. Everything in the panel is translated: en, es, and pt, 1769 keys.
Replacing an active pack
Without
override: truethe server still 409s, withcan_override: trueso the panel knows to ask. Withoverride: truethe server demotes the official pack, activates the newmanual-<cc>version, and records the replaced pack id plus the count of dormant overrides in the audit.Merchant overrides stay dormant instead of being re-pointed. A manual pack applies one rate to every category, so re-pointing an override would change no tax outcome but would lose the
categoryIdthe owner chose (for exampleiva_105going tostandard). That data is gone forever once re-pointed. Dormant overrides keep the original value and start working again if the official pack is reactivated. The count of dormant overrides shows up in the response, the audit, and a warning toast.Stale categories are remapped and recorded
backfillCategoryDefaultsruns on every pack swap. It is the only thing keeping checkout from returning 400 on every line when the new pack does not define the category a product is on (for exampleiva_21after switching from Argentina to a manual pack).The spec (Decision K) said this remap has to be recorded. The function now returns the mapping as
{entity, from, to, count}[]and writes aremap_categoriesaudit row. The panel also gets the mapping in the response and shows a warning toast:Products that had no category at all are still treated as a backfill, not a loss, and are not reported.
What the owner actually sees when they type a manual value
The receipt tax-id label comes from
resolveTaxIdLabel(country). Before the fix, this function returned the active pack'sregistrationNumberLabelwhenever the pack was active.local-genericis the no-tax pack that ships with every install. Its country is*and its label is "Tax registration". On a Thailand install with no official pack yet, this generic pack was active for TH, and the receipt printed "Tax registration: TAXID-0001" instead of the correct "Tax ID: TAXID-0001" frommain/countries.ts.The fix at
main/services/tax.ts:113is one line: a pack's label is only trusted when its country matches the requested country. So:AR, the label isCUIT. Receipt prints "CUIT: 30-...".local-genericis still the active pack, but its country*does not matchTH, so the function falls through tocountries.tsand returns "Tax ID".manual-thpack hascountry: 'TH', so its label (whatever the owner typed, e.g. "Tax ID No.") wins. If the owner leaves the label blank, it defaults to "Tax registration" and that label is now trusted for TH.For the manual pack prefill, the same one-line guard used to look like
detail.pack.publisher === 'local' && detail.pack.country === storeCountry. That country check excluded thelocal-genericwildcard (country*) so the form did not prefill from a pristine install. But once the manual pack for the store's country was created, it had its own country code and the check passed. The real question the panel needs to answer is not "is this pack for my country" but "does this pack have a rate". The wildcard pack has zero rules; a saved manual pack always has one. The check is nowdetail.rules.find((rule) => rule.rate)?.rate, and a new test intests/tax-pack-management.test.tsasserts both halves:local-generichas no rules, a savedmanual-<cc>has exactly one.Diff size
12 files, +1682 / −189 against
main. Largest file:tests/tax-pack-management.test.tsat +396 lines.