* 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>
371 lines
14 KiB
TypeScript
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 20260903170000 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
|
|
* 20260903170000) 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 20260903170000 repairs the old ones
|
|
* whose country was only ever the writer default.
|
|
*/
|
|
export function countryPermitsReverseCharge(country: string | null | undefined): boolean {
|
|
return normalizeCountryCode(country) !== 'SE'
|
|
}
|