Files
accounted/lib/vat/country-codes.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01: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'
}