Files
accounted/lib/vat/country-codes.ts
MattssonandClaude Fable 5.1 3159920d7c fix(customers): re-issue the country backfill skipping migration-reset source companies (#2249)
* fix(customers): re-issue the country backfill skipping migration-reset source companies

Migration 20260903170000 (PR #2241) failed on prod at its first UPDATE:
"Archived migration reset source records are immutable" (SQLSTATE P0001).
Rows of a company listed in company_migration_resets are frozen by
trigger, and the backfill touched them, so the whole file rolled back and
prod has neither normalize_country_code() nor country_raw.

The same SQL ships again as 20260903173000 with every UPDATE excluding
those companies (their legacy text keeps being read through
normalizeCountryCode() at runtime). The old file is removed rather than
edited: prod never recorded it, staging was re-tracked under the new
version by hand. References in code, tests and DECISIONS.md follow.

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

* docs(decisions): cite the shipped backfill version 20260903173000

The country-ISO entry still named 20260903170000, the version that never
landed on prod; only the follow-up entry keeps that number, as history.

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

* docs(decisions): drop the duplicate country-ISO entry the merge carried in

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

* docs(decisions): keep a single country-ISO entry

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

* docs(decisions): rebuild the tail from main so the merge leaves no duplicated entries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:39:56 +02:00

371 lines
14 KiB
TypeScript

/**
* Customer and supplier country: ISO 3166-1 alpha-2 everywhere.
*
* `customers.country` and `suppliers.country` are read as ISO codes by every
* consumer that cares (periodisk sammanställning / SKV 5740, Peppol BIS
* Billing, the provider importers, the VAT-treatment rules). Until 2026-09
* the customer form and the v1 API wrote English names ("Sweden", "Germany")
* into the same column, which put GERMANY811234567 in the SKV 5740 file and
* made the report's own EU checks fire on correct data (#2028), and let an EU
* customer be saved with land Sverige and still get reverse charge (#2025).
*
* This module is the single place that knows how to turn what a user, an
* agent or an old row says into a code, and which code goes with which
* customer type.
*/
import { EU_COUNTRIES, isEuMemberCountry } from './eu-countries'
export interface CountryOption {
/** ISO 3166-1 alpha-2 */
code: string
/** Swedish name */
name: string
/** English name */
nameEn: string
}
/**
* Non-EU countries offered in the pickers. Not the whole ISO list: the API
* accepts any well-formed alpha-2 code, this is only what the form shows.
*/
export const NON_EU_COUNTRIES: CountryOption[] = [
{ code: 'NO', name: 'Norge', nameEn: 'Norway' },
{ code: 'GB', name: 'Storbritannien', nameEn: 'United Kingdom' },
{ code: 'CH', name: 'Schweiz', nameEn: 'Switzerland' },
{ code: 'IS', name: 'Island', nameEn: 'Iceland' },
{ code: 'LI', name: 'Liechtenstein', nameEn: 'Liechtenstein' },
{ code: 'US', name: 'USA', nameEn: 'United States' },
{ code: 'CA', name: 'Kanada', nameEn: 'Canada' },
{ code: 'MX', name: 'Mexiko', nameEn: 'Mexico' },
{ code: 'BR', name: 'Brasilien', nameEn: 'Brazil' },
{ code: 'AU', name: 'Australien', nameEn: 'Australia' },
{ code: 'NZ', name: 'Nya Zeeland', nameEn: 'New Zealand' },
{ code: 'JP', name: 'Japan', nameEn: 'Japan' },
{ code: 'CN', name: 'Kina', nameEn: 'China' },
{ code: 'HK', name: 'Hongkong', nameEn: 'Hong Kong' },
{ code: 'KR', name: 'Sydkorea', nameEn: 'South Korea' },
{ code: 'IN', name: 'Indien', nameEn: 'India' },
{ code: 'SG', name: 'Singapore', nameEn: 'Singapore' },
{ code: 'TH', name: 'Thailand', nameEn: 'Thailand' },
{ code: 'AE', name: 'Förenade Arabemiraten', nameEn: 'United Arab Emirates' },
{ code: 'IL', name: 'Israel', nameEn: 'Israel' },
{ code: 'TR', name: 'Turkiet', nameEn: 'Turkey' },
{ code: 'UA', name: 'Ukraina', nameEn: 'Ukraine' },
{ code: 'RS', name: 'Serbien', nameEn: 'Serbia' },
{ code: 'ZA', name: 'Sydafrika', nameEn: 'South Africa' },
{ code: 'CO', name: 'Colombia', nameEn: 'Colombia' },
{ code: 'CW', name: 'Curaçao', nameEn: 'Curaçao' },
{ code: 'KN', name: 'Saint Kitts och Nevis', nameEn: 'Saint Kitts and Nevis' },
]
/** Every country the pickers offer: the EU 27 first, then the non-EU list. */
export const COUNTRY_OPTIONS: CountryOption[] = [
...EU_COUNTRIES.map(({ code, name, nameEn }) => ({ code, name, nameEn })),
...NON_EU_COUNTRIES,
]
const OPTION_BY_CODE = new Map(COUNTRY_OPTIONS.map((c) => [c.code, c]))
/**
* Spellings that are neither an ISO code nor one of the two names above but
* that real rows and real spreadsheets carry. Keyed by folded name.
*/
const NAME_ALIASES: Record<string, string> = {
sverige: 'SE',
sweden: 'SE',
deutschland: 'DE',
holland: 'NL',
'the netherlands': 'NL',
nederlanderna: 'NL',
osterrike: 'AT',
'czech republic': 'CZ',
czechia: 'CZ',
tjeckien: 'CZ',
'republic of ireland': 'IE',
usa: 'US',
'united states of america': 'US',
'u.s.a.': 'US',
uk: 'GB',
england: 'GB',
'great britain': 'GB',
britain: 'GB',
'united kingdom of great britain and northern ireland': 'GB',
norway: 'NO',
norge: 'NO',
schweiz: 'CH',
switzerland: 'CH',
suisse: 'CH',
'south korea': 'KR',
'republic of korea': 'KR',
'hong kong': 'HK',
uae: 'AE',
turkiye: 'TR',
'türkiye': 'TR',
'st kitts & nevis': 'KN',
'st kitts and nevis': 'KN',
'st. kitts and nevis': 'KN',
'saint kitts & nevis': 'KN',
}
/** Lowercase, trimmed, single-spaced, without trailing periods. */
function foldName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/\.+$/, '')
}
const NAME_TO_CODE: Map<string, string> = (() => {
const map = new Map<string, string>()
for (const option of COUNTRY_OPTIONS) {
map.set(foldName(option.name), option.code)
map.set(foldName(option.nameEn), option.code)
}
for (const [alias, code] of Object.entries(NAME_ALIASES)) {
map.set(foldName(alias), code)
}
return map
})()
const ALPHA2_RE = /^[A-Z]{2}$/
/**
* Every folded name the TypeScript table knows, with its code. Exists so a
* test can hold the SQL twin in migration 20260903173000 to the same table.
*/
export function listKnownCountryNames(): Array<[name: string, code: string]> {
return [...NAME_TO_CODE.entries()]
}
/**
* Turn user, agent or legacy input into an ISO 3166-1 alpha-2 code.
*
* Accepts a code in any case ("de", "DE"), the Skatteverket/VIES spelling
* of Greece ("EL"), the customary "UK", and the Swedish and English names of
* every country in COUNTRY_OPTIONS plus the aliases above. Returns null for
* anything else (empty input included): the caller decides whether that is
* "unknown, keep the raw text" (reports, backfill) or a 400 (API writes).
*
* The SQL twin `public.normalize_country_code(text)` (migration
* 20260903173000) carries the same table for the one-off backfill; keep them
* in step when adding names here.
*/
export function normalizeCountryCode(input: string | null | undefined): string | null {
if (input == null) return null
const trimmed = input.trim()
if (!trimmed) return null
const upper = trimmed.toUpperCase()
if (ALPHA2_RE.test(upper)) {
if (upper === 'EL') return 'GR'
if (upper === 'UK') return 'GB'
return upper
}
return NAME_TO_CODE.get(foldName(trimmed)) ?? null
}
/** True when the value already is a well-formed uppercase alpha-2 code. */
export function isCountryCode(value: string | null | undefined): boolean {
return typeof value === 'string' && ALPHA2_RE.test(value)
}
/**
* Display name for a code in the given locale; the code itself when the
* country is not in COUNTRY_OPTIONS, and the raw value when it is not a
* code at all (an unmapped legacy row).
*/
export function getCountryName(code: string | null | undefined, locale: 'sv' | 'en' = 'sv'): string {
if (!code) return ''
const normalized = normalizeCountryCode(code)
const option = normalized ? OPTION_BY_CODE.get(normalized) : undefined
if (!option) return normalized ?? code
return locale === 'en' ? option.nameEn : option.name
}
/**
* COUNTRY_OPTIONS ordered for a picker: Sweden first, the rest by name in
* the given locale.
*/
export function getCountryOptions(locale: 'sv' | 'en' = 'sv'): CountryOption[] {
const label = (c: CountryOption) => (locale === 'en' ? c.nameEn : c.name)
const collator = new Intl.Collator(locale === 'en' ? 'en' : 'sv')
return [...COUNTRY_OPTIONS].sort((a, b) => {
if (a.code === 'SE') return -1
if (b.code === 'SE') return 1
return collator.compare(label(a), label(b))
})
}
// ---------------------------------------------------------------------------
// Country vs customer type
// ---------------------------------------------------------------------------
export type CountryPartyType =
| 'individual'
| 'swedish_business'
| 'eu_business'
| 'non_eu_business'
export type CountryConsistencyIssue =
| 'SWEDISH_BUSINESS_REQUIRES_SE'
| 'EU_BUSINESS_COUNTRY_IS_SE'
| 'EU_BUSINESS_REQUIRES_EU_COUNTRY'
| 'NON_EU_BUSINESS_REQUIRES_NON_EU_COUNTRY'
| 'VAT_PREFIX_COUNTRY_MISMATCH'
export const COUNTRY_CONSISTENCY_MESSAGES: Record<
CountryConsistencyIssue,
{ sv: string; en: string }
> = {
SWEDISH_BUSINESS_REQUIRES_SE: {
sv: 'Ett svenskt företag måste ha landet Sverige. Välj kundtypen EU-företag eller Företag utanför EU för en utländsk kund.',
en: 'A Swedish business must have country SE. Choose EU business or Non-EU business for a foreign customer.',
},
EU_BUSINESS_COUNTRY_IS_SE: {
sv: 'Ett EU-företag kan inte ha landet Sverige. Välj kundens land, eller kundtypen Svenskt företag om kunden är svensk.',
en: 'An EU business cannot have country SE. Pick the customer\'s country, or choose Swedish business if the customer is Swedish.',
},
EU_BUSINESS_REQUIRES_EU_COUNTRY: {
sv: 'Ett EU-företag måste ha ett land inom EU, eller ett VAT-nummer registrerat i ett annat EU-land. Välj annars kundtypen Företag utanför EU.',
en: 'An EU business must have an EU country, or a VAT number registered in another EU country. Otherwise choose Non-EU business.',
},
NON_EU_BUSINESS_REQUIRES_NON_EU_COUNTRY: {
sv: 'Ett företag utanför EU kan inte ha ett EU-land. Välj kundtypen EU-företag, eller Svenskt företag för Sverige.',
en: 'A non-EU business cannot have an EU country. Choose EU business, or Swedish business for Sweden.',
},
VAT_PREFIX_COUNTRY_MISMATCH: {
sv: 'VAT-numrets landsprefix stämmer inte med kundens land.',
en: 'The VAT number\'s country prefix does not match the customer\'s country.',
},
}
/**
* VIES prefixes that name an EU VAT registration: the 27 members (EL for
* Greece) plus XI, Northern Ireland, which stays in the EU goods VAT area
* under the Protocol and appears with that prefix in VIES and SKV 5740.
*/
const EU_TRADE_VAT_PREFIXES = new Set([...EU_COUNTRIES.map((c) => c.vatPrefix), 'XI'])
/**
* Territories inside another member's VAT area: a customer there carries
* that member's prefix (Monaco is French for VAT, Article 7 of the VAT
* Directive) and is an EU customer for reverse charge and SKV 5740.
*/
const VAT_TERRITORY_OF: Record<string, string> = { MC: 'FR' }
/** The VIES/Skatteverket prefix for an ISO code (Greece is EL, Monaco is FR). */
export function vatPrefixForCountry(code: string): string {
const member = VAT_TERRITORY_OF[code] ?? code
return member === 'GR' ? 'EL' : member
}
/** True when the prefix names a VAT registration in an EU country other than Sweden. */
export function isEuTradeVatPrefix(prefix: string | null | undefined): boolean {
return !!prefix && prefix !== 'SE' && EU_TRADE_VAT_PREFIXES.has(prefix)
}
/** True when the country is in the EU VAT area: a member, or a territory of one. */
export function isEuVatAreaCountry(code: string): boolean {
return isEuMemberCountry(code) || code in VAT_TERRITORY_OF
}
/**
* The two-letter prefix a VAT number starts with, when it starts with one.
* Whitespace and dots are ignored; "811234567" (no prefix) gives null.
*/
export function vatNumberCountryPrefix(vatNumber: string | null | undefined): string | null {
if (!vatNumber) return null
const cleaned = vatNumber.replace(/[\s.\-]/g, '').toUpperCase()
const match = cleaned.match(/^([A-Z]{2})/)
return match ? match[1] : null
}
/**
* The country a NEW row gets when the caller did not supply one.
*
* Swedish business and individual: SE (the column default). EU business: the
* country the VAT number's prefix names, when it names an EU member other
* than Sweden; without a usable prefix there is nothing to derive from and
* the caller has to say. Non-EU business: always the caller's to say. Null
* means "required, not supplied".
*/
export function defaultCountryForParty(
partyType: string,
vatNumber?: string | null,
): string | null {
if (partyType === 'eu_business') {
const prefix = vatNumberCountryPrefix(vatNumber)
if (!prefix) return null
const code = prefix === 'EL' ? 'GR' : prefix
return code !== 'SE' && isEuMemberCountry(code) ? code : null
}
if (partyType === 'non_eu_business') return null
return 'SE'
}
/**
* Does the country agree with the party type (and the VAT number's prefix)?
*
* - swedish_business: country must be SE.
* - eu_business: country must not be SE. Inside the EU VAT area (a member,
* or Monaco) a VAT number that carries a prefix must carry that country's
* prefix. Outside it the customer counts as EU only through an EU VAT
* registration: a Swiss company registered in Germany, or Northern
* Ireland with its XI prefix.
* - non_eu_business: country must not be an EU member (SE included).
* - individual: no rule. A private person abroad is still a customer taxed
* with Swedish VAT (or OSS), so the country is free.
*
* A country that is missing or not recognisable as a code gives null: there
* is nothing to compare. Callers that require a code check that first.
*/
export function checkCountryConsistency(input: {
partyType: string
country: string | null | undefined
vatNumber?: string | null
}): CountryConsistencyIssue | null {
const code = normalizeCountryCode(input.country)
if (!code) return null
switch (input.partyType) {
case 'swedish_business':
return code === 'SE' ? null : 'SWEDISH_BUSINESS_REQUIRES_SE'
case 'eu_business': {
if (code === 'SE') return 'EU_BUSINESS_COUNTRY_IS_SE'
const prefix = vatNumberCountryPrefix(input.vatNumber)
if (isEuVatAreaCountry(code)) {
if (prefix && prefix !== vatPrefixForCountry(code)) return 'VAT_PREFIX_COUNTRY_MISMATCH'
return null
}
return isEuTradeVatPrefix(prefix) ? null : 'EU_BUSINESS_REQUIRES_EU_COUNTRY'
}
case 'non_eu_business':
return isEuMemberCountry(code) ? 'NON_EU_BUSINESS_REQUIRES_NON_EU_COUNTRY' : null
default:
return null
}
}
/**
* Does this country allow the reverse-charge (0%, ruta 39) treatment an
* EU-business customer with a validated VAT number gets?
*
* Only Sweden refuses it: a buyer established here owes Swedish VAT
* whatever foreign number it also holds (#2025). Any other country keeps
* it, because the VIES-validated number is the stronger evidence of an EU
* registration than the address (a Swiss company registered in Germany,
* Monaco with a French number, Northern Ireland with XI). An unknown or
* unmapped country (null, or legacy text that is not a code) does not block
* either: refusing reverse charge on a genuine German customer whose row
* still says "Deutschland" would put Swedish VAT on a correct invoice, which
* is the worse error. The consistency check above stops new contradictory
* rows from being saved, and migration 20260903173000 repairs the old ones
* whose country was only ever the writer default.
*/
export function countryPermitsReverseCharge(country: string | null | undefined): boolean {
return normalizeCountryCode(country) !== 'SE'
}