fix(customers): make country ISO-2 everywhere and check it against the customer type (#2241)

* fix(customers): make country ISO-2 everywhere and check it against the customer type (#2025, #2028)

customers.country and suppliers.country were read as ISO codes by the
periodisk sammanstallning (SKV 5740), Peppol and the provider importers but
written as English names by the customer form and the v1 API, so a correct
German customer produced GERMANY811234567 in the SKV file plus two false
warnings, and an EU customer saved with land Sverige got reverse charge with
nothing objecting until after the invoice was sent.

- lib/vat/country-codes.ts: one helper that normalises codes and the
  Swedish/English names the writers used to store, the country-vs-type
  rule (swedish_business = SE, eu_business = EU member other than SE that
  matches the VAT prefix, non_eu_business = outside the EU), and the
  reverse-charge country gate.
- Writers: customer form and supplier form get a country select; internal
  REST, v1 REST, bulk-create, MCP create/update, CSV/Excel import and the
  provider migration mapper normalise to a code and refuse unknown text;
  the consistency rule is a form error and an API 400
  (CUSTOMER_COUNTRY_MISMATCH on update). An omitted country is SE for
  Swedish types, derived from the VAT prefix for eu_business, required
  for non_eu_business.
- vat-rules.ts: getVatRules and friends take the country as a third
  argument and grant reverse charge only for an EU country other than SE;
  every invoice/sales-order/MCP call site passes customer.country.
- periodisk sammanstallning reads legacy names through the same helper.
- Migration 20260903170000: normalize_country_code() SQL twin, country_raw
  rollback column on both tables, backfill of every non-code row; unknown
  text is left as-is. pg-real test for the function.

Closes #2025, closes #2028

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* fix(customers): keep reverse charge for defaulted-SE EU rows, gate the country rule on the fields it reads, fix build

Skeptic and CI findings on #2241, one pass:

- Migration step 4: eu_business rows whose country was null or only the old
  writer default (SE) while the VAT number names another EU member take the
  country from the prefix. The pre-2026-09 rules granted reverse charge on
  type + VIES validation alone, so these rows invoiced at 0% and would have
  flipped to 25% on the next invoice. country_raw = '' marks a null origin;
  rollback uses nullif(country_raw, '').
- countryPermitsReverseCharge refuses SE only: a VIES-validated number
  outweighs a non-EU address (Swiss company registered in DE, Monaco with a
  FR number, Northern Ireland XI).
- checkCountryConsistency: an eu_business outside the EU VAT area is
  accepted when the VAT prefix is an EU-trade registration (incl. XI);
  Monaco maps to the FR prefix.
- Internal PATCH, MCP update and the commit executor judge the country rule
  only when customer_type, country or vat_number is part of the update, so
  a contradictory legacy row can still change its email (v1 already did).
- Webshop-order customers get the order's billing country; spreadsheet
  import derives a missing country from the type and flags contradictions
  (parser row error + execute schema refine).
- Build: v1 [id] route typed the existing row through a narrowed alias
  (never) and passed messageSv/messageEn the v1 error context lacks; the
  self-billed customer projection lacked country.
- Checks: regenerated skills/accounted-api (customer example country SE).
- New parity test holds the migration's SQL name table to the TS table.
- DECISIONS.md: correct migration version and the revised rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 18:09:46 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 80b87c55fc
commit 3918ff6620
48 changed files with 2288 additions and 97 deletions
@@ -0,0 +1,233 @@
-- customers.country and suppliers.country are ISO 3166-1 alpha-2 (#2025, #2028).
--
-- The columns have always defaulted to 'SE' and every reader (periodisk
-- sammanställning / SKV 5740, Peppol BIS Billing, the provider importers)
-- treats the value as a code, but the customer form and the v1 API wrote
-- English names ("Sweden", "Germany"). This migration:
--
-- 1. adds public.normalize_country_code(text): the SQL twin of
-- normalizeCountryCode() in lib/vat/country-codes.ts (same table);
-- 2. keeps the pre-backfill text in a new country_raw column on both
-- tables for every row it touches, so the backfill is one UPDATE to undo;
-- 3. maps every row whose country is not already an uppercase code through
-- the function. Names the table does not know are left exactly as they
-- were (and listed in country_raw); the periodisk report already warns
-- on those and the customer form asks for a pick before it saves.
--
-- 4. for eu_business rows whose country is missing or only the old writer
-- default (SE) while the VAT number names another EU member, takes the
-- country from the VAT prefix. The pre-2026-09 VAT rules granted reverse
-- charge on type + VIES validation alone, so these rows invoiced at 0%;
-- without this step they would flip to 25% Swedish VAT on the next
-- invoice. country_raw = '' marks a row whose country was null.
--
-- Rollback (restores the original text on every touched row):
-- update public.customers set country = nullif(country_raw, '') where country_raw is not null;
-- update public.suppliers set country = nullif(country_raw, '') where country_raw is not null;
--
-- Rows still unmapped after the backfill:
-- select id, company_id, name, country from public.customers
-- where country is not null and country !~ '^[A-Z]{2}$';
-- (same for public.suppliers)
create or replace function public.normalize_country_code(input text)
returns text
language plpgsql
immutable
as $$
declare
folded text;
upper_input text;
begin
folded := lower(regexp_replace(btrim(coalesce(input, '')), '\s+', ' ', 'g'));
folded := regexp_replace(folded, '\.+$', '');
if folded = '' then
return null;
end if;
upper_input := upper(btrim(input));
if upper_input ~ '^[A-Z]{2}$' then
if upper_input = 'EL' then return 'GR'; end if;
if upper_input = 'UK' then return 'GB'; end if;
return upper_input;
end if;
return (
select m.code
from (values
('australia', 'AU'),
('australien', 'AU'),
('austria', 'AT'),
('belgien', 'BE'),
('belgium', 'BE'),
('brasilien', 'BR'),
('brazil', 'BR'),
('britain', 'GB'),
('bulgaria', 'BG'),
('bulgarien', 'BG'),
('canada', 'CA'),
('china', 'CN'),
('colombia', 'CO'),
('croatia', 'HR'),
('curaçao', 'CW'),
('cypern', 'CY'),
('cyprus', 'CY'),
('czech republic', 'CZ'),
('czechia', 'CZ'),
('danmark', 'DK'),
('denmark', 'DK'),
('deutschland', 'DE'),
('england', 'GB'),
('estland', 'EE'),
('estonia', 'EE'),
('finland', 'FI'),
('france', 'FR'),
('frankrike', 'FR'),
('förenade arabemiraten', 'AE'),
('germany', 'DE'),
('great britain', 'GB'),
('greece', 'GR'),
('grekland', 'GR'),
('holland', 'NL'),
('hong kong', 'HK'),
('hongkong', 'HK'),
('hungary', 'HU'),
('iceland', 'IS'),
('india', 'IN'),
('indien', 'IN'),
('ireland', 'IE'),
('irland', 'IE'),
('island', 'IS'),
('israel', 'IL'),
('italien', 'IT'),
('italy', 'IT'),
('japan', 'JP'),
('kanada', 'CA'),
('kina', 'CN'),
('kroatien', 'HR'),
('latvia', 'LV'),
('lettland', 'LV'),
('liechtenstein', 'LI'),
('litauen', 'LT'),
('lithuania', 'LT'),
('luxembourg', 'LU'),
('luxemburg', 'LU'),
('malta', 'MT'),
('mexico', 'MX'),
('mexiko', 'MX'),
('nederlanderna', 'NL'),
('nederländerna', 'NL'),
('netherlands', 'NL'),
('new zealand', 'NZ'),
('norge', 'NO'),
('norway', 'NO'),
('nya zeeland', 'NZ'),
('osterrike', 'AT'),
('poland', 'PL'),
('polen', 'PL'),
('portugal', 'PT'),
('republic of ireland', 'IE'),
('republic of korea', 'KR'),
('romania', 'RO'),
('rumänien', 'RO'),
('saint kitts & nevis', 'KN'),
('saint kitts and nevis', 'KN'),
('saint kitts och nevis', 'KN'),
('schweiz', 'CH'),
('serbia', 'RS'),
('serbien', 'RS'),
('singapore', 'SG'),
('slovakia', 'SK'),
('slovakien', 'SK'),
('slovenia', 'SI'),
('slovenien', 'SI'),
('south africa', 'ZA'),
('south korea', 'KR'),
('spain', 'ES'),
('spanien', 'ES'),
('st kitts & nevis', 'KN'),
('st kitts and nevis', 'KN'),
('st. kitts and nevis', 'KN'),
('storbritannien', 'GB'),
('suisse', 'CH'),
('sverige', 'SE'),
('sweden', 'SE'),
('switzerland', 'CH'),
('sydafrika', 'ZA'),
('sydkorea', 'KR'),
('thailand', 'TH'),
('the netherlands', 'NL'),
('tjeckien', 'CZ'),
('turkey', 'TR'),
('turkiet', 'TR'),
('turkiye', 'TR'),
('tyskland', 'DE'),
('türkiye', 'TR'),
('u.s.a', 'US'),
('uae', 'AE'),
('uk', 'GB'),
('ukraina', 'UA'),
('ukraine', 'UA'),
('ungern', 'HU'),
('united arab emirates', 'AE'),
('united kingdom', 'GB'),
('united kingdom of great britain and northern ireland', 'GB'),
('united states', 'US'),
('united states of america', 'US'),
('usa', 'US'),
('österrike', 'AT')
) as m(name, code)
where m.name = folded
limit 1
);
end;
$$;
comment on function public.normalize_country_code(text) is
'ISO 3166-1 alpha-2 from a code in any case, EL/UK, or a Swedish/English country name; null when unknown. Mirrors lib/vat/country-codes.ts.';
alter table public.customers add column if not exists country_raw text;
alter table public.suppliers add column if not exists country_raw text;
comment on column public.customers.country_raw is
'The free-text country the row held before the 2026-09 ISO backfill, kept for rollback; null for rows the backfill did not touch.';
comment on column public.suppliers.country_raw is
'The free-text country the row held before the 2026-09 ISO backfill, kept for rollback; null for rows the backfill did not touch.';
-- Backfill: every row that is not already an uppercase alpha-2 code. An
-- empty string is "no country" and becomes null (the periodisk report
-- already treats both the same); a null stays null, nothing is guessed.
update public.customers
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
update public.suppliers
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
-- Step 4: EU-business rows with a missing or defaulted (SE) country and a
-- VAT number registered in another EU member. Only a prefix that names a
-- member is used; a number without a prefix derives nothing and the row
-- keeps its country.
update public.customers c
set country_raw = coalesce(c.country_raw, c.country, ''),
country = d.code
from (
select id,
public.normalize_country_code(
substring(upper(regexp_replace(coalesce(vat_number, ''), '[\s.\-]', '', 'g')) from 1 for 2)
) as code
from public.customers
where customer_type = 'eu_business'
and (country is null or country = 'SE')
) d
where d.id = c.id
and d.code is not null
and d.code <> 'SE'
and d.code in ('AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES');
NOTIFY pgrst, 'reload schema';