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:
co-authored by
Claude Fable 5.1
parent
80b87c55fc
commit
3918ff6620
+84
-6
@@ -24,6 +24,12 @@ import {
|
||||
} from '@/lib/invoices/rot-rut-rules'
|
||||
import { NON_IBAN_CURRENCIES } from '@/lib/invoices/payment-accounts'
|
||||
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
|
||||
import {
|
||||
COUNTRY_CONSISTENCY_MESSAGES,
|
||||
checkCountryConsistency,
|
||||
defaultCountryForParty,
|
||||
normalizeCountryCode,
|
||||
} from '@/lib/vat/country-codes'
|
||||
import {
|
||||
looksLikeSwedishPersonalNumber,
|
||||
normalizeReroutedPersonalNumber,
|
||||
@@ -945,6 +951,29 @@ export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
|
||||
// Customer schemas
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 country on customers and suppliers. A code in any case
|
||||
* ("de", "DE"), Skatteverket's EL for Greece, or a Swedish/English country
|
||||
* name ("Tyskland", "Germany") is normalised to the uppercase code; anything
|
||||
* else is a 400. The column used to take free text, which put
|
||||
* GERMANY811234567 in the SKV 5740 file (#2028). Empty string reads as
|
||||
* "not supplied".
|
||||
*/
|
||||
const countryCode = z.string().transform((value, ctx) => {
|
||||
const code = normalizeCountryCode(value)
|
||||
if (!code) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
`country "${value}" is not an ISO 3166-1 alpha-2 code or a known country name. `
|
||||
+ 'Use a code such as SE, DE or NO.',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return code
|
||||
})
|
||||
export const CountryCodeSchema = emptyStringAsUndefined(countryCode)
|
||||
|
||||
export const CreateCustomerSchema = z.object({
|
||||
name: z.string().min(1, 'Customer name is required'),
|
||||
customer_type: CustomerTypeSchema,
|
||||
@@ -965,7 +994,7 @@ export const CreateCustomerSchema = z.object({
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country: CountryCodeSchema,
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
personal_number: z
|
||||
@@ -984,6 +1013,37 @@ export const CreateCustomerSchema = z.object({
|
||||
message: 'Personal number is only allowed for individual customers',
|
||||
})
|
||||
}
|
||||
// Country vs customer type vs VAT prefix (#2025): an EU business with
|
||||
// country SE got reverse charge and nothing objected until the periodisk
|
||||
// sammanställning, after the invoice was sent. An omitted country is SE
|
||||
// for Swedish types, derived from the VAT prefix for eu_business, and
|
||||
// required for non_eu_business (see defaultCountryForParty); the
|
||||
// transform below stores the resolved value.
|
||||
const effectiveCountry =
|
||||
customer.country ?? defaultCountryForParty(customer.customer_type, customer.vat_number)
|
||||
if (!effectiveCountry) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['country'],
|
||||
message:
|
||||
customer.customer_type === 'eu_business'
|
||||
? 'country is required for an EU business unless vat_number carries an EU country prefix (e.g. DE811234567)'
|
||||
: 'country is required for a non-EU business',
|
||||
})
|
||||
} else {
|
||||
const countryIssue = checkCountryConsistency({
|
||||
partyType: customer.customer_type,
|
||||
country: effectiveCountry,
|
||||
vatNumber: customer.vat_number,
|
||||
})
|
||||
if (countryIssue) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['country'],
|
||||
message: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en,
|
||||
})
|
||||
}
|
||||
}
|
||||
// GDPR art. 5.1 c: a personnummer stored as a business org_number is shown
|
||||
// unmasked everywhere (only customer_type='individual' rows are masked), so
|
||||
// refuse to accept one silently.
|
||||
@@ -1029,7 +1089,13 @@ export const CreateCustomerSchema = z.object({
|
||||
message: `At most ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} customer invoice copy recipients are allowed in total`,
|
||||
})
|
||||
}
|
||||
}).transform((customer) => {
|
||||
}).transform((input) => {
|
||||
// The resolved country (see the superRefine above): always a code here,
|
||||
// the refine has already rejected the cases where none can be derived.
|
||||
const customer = {
|
||||
...input,
|
||||
country: input.country ?? defaultCountryForParty(input.customer_type, input.vat_number) ?? 'SE',
|
||||
}
|
||||
// A personnummer-shaped org_number on customer_type='individual' IS the
|
||||
// personnummer, submitted in the wrong field (the MCP create tool had no
|
||||
// personal_number input until 2026-08-21, and the v1 docs long said
|
||||
@@ -1059,7 +1125,7 @@ export const UpdateCustomerSchema = z.object({
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country: CountryCodeSchema,
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
// Plaintext personnummer (validated here, then encrypted by the route), or
|
||||
@@ -1123,7 +1189,7 @@ export const CreateSupplierSchema = z.object({
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country: CountryCodeSchema,
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
bankgiro: z.string().optional(),
|
||||
@@ -2762,10 +2828,22 @@ const ImportedCustomerRowSchema = z.object({
|
||||
address_line2: z.string().nullable(),
|
||||
postal_code: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
country: z.string(),
|
||||
country: countryCode,
|
||||
vat_number: z.string().nullable(),
|
||||
default_payment_terms: z.number().int().min(0).max(365),
|
||||
notes: z.string().nullable(),
|
||||
}).superRefine((row, ctx) => {
|
||||
// The preview flags these rows and the wizard refuses to continue with
|
||||
// them; repeated here so a hand-built request cannot import an EU
|
||||
// business with country SE (#2025).
|
||||
const countryIssue = checkCountryConsistency({
|
||||
partyType: row.customer_type,
|
||||
country: row.country,
|
||||
vatNumber: row.vat_number,
|
||||
})
|
||||
if (countryIssue) {
|
||||
ctx.addIssue({ code: 'custom', path: ['country'], message: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en })
|
||||
}
|
||||
})
|
||||
|
||||
export const CustomerImportExecuteSchema = z.object({
|
||||
@@ -2784,7 +2862,7 @@ const ImportedSupplierRowSchema = z.object({
|
||||
address_line2: z.string().nullable(),
|
||||
postal_code: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
country: z.string(),
|
||||
country: countryCode,
|
||||
vat_number: z.string().nullable(),
|
||||
bankgiro: z.string().nullable(),
|
||||
plusgiro: z.string().nullable(),
|
||||
|
||||
@@ -2759,6 +2759,11 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'The org number looks like a Swedish personal identity number. Save the customer as an individual instead, so the number is stored protected and masked in lists.',
|
||||
},
|
||||
CUSTOMER_COUNTRY_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Landet stämmer inte med kundtypen eller VAT-numrets landsprefix.',
|
||||
message_en: 'The country does not agree with the customer type or the VAT number\'s country prefix.',
|
||||
},
|
||||
CUSTOMER_PERSONAL_NUMBER_CONFLICT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
|
||||
@@ -131,4 +131,45 @@ describe('parseCustomersFile', () => {
|
||||
expect(result.rows[0].city).toBe('GÖTEBORG')
|
||||
expect(result.rows[1].city).toBe('HISINGS KÄRRA')
|
||||
})
|
||||
|
||||
it('stores the country as an ISO code and flags a name it cannot map', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Orgnr', 'Land'],
|
||||
['Acme AB', '5560217780', 'Sverige'],
|
||||
['Muster GmbH', '', 'Tyskland'],
|
||||
['Nowhere Ltd', '', 'Atlantis'],
|
||||
['Blank AB', '5562345678', ''],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'kunder.xlsx')
|
||||
|
||||
expect(result.rows[0].country).toBe('SE')
|
||||
expect(result.rows[1].country).toBe('DE')
|
||||
expect(result.rows[2].country).toBe('Atlantis')
|
||||
expect(result.rows[2].is_valid).toBe(false)
|
||||
expect(result.rows[2].validation_errors.join(' ')).toMatch(/Okänt land/)
|
||||
expect(result.rows[3].country).toBe('SE')
|
||||
})
|
||||
|
||||
it('derives a missing country from the type and flags a contradictory row', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Kundtyp', 'Land', 'VAT-nummer'],
|
||||
['Muster GmbH', 'eu_business', '', 'DE811234567'],
|
||||
['Falsch GmbH', 'eu_business', 'Sverige', 'DE811234567'],
|
||||
['Far Ltd', 'non_eu_business', '', ''],
|
||||
['Acme AB', 'swedish_business', '', ''],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'kunder.xlsx')
|
||||
|
||||
expect(result.rows[0].country).toBe('DE')
|
||||
expect(result.rows[0].is_valid).toBe(true)
|
||||
expect(result.rows[1].country).toBe('SE')
|
||||
expect(result.rows[1].is_valid).toBe(false)
|
||||
expect(result.rows[1].validation_errors.join(' ')).toMatch(/EU-företag/)
|
||||
expect(result.rows[2].is_valid).toBe(false)
|
||||
expect(result.rows[2].validation_errors.join(' ')).toMatch(/Land saknas/)
|
||||
expect(result.rows[3].country).toBe('SE')
|
||||
expect(result.rows[3].is_valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { CustomerType } from '@/types'
|
||||
import { detectCustomerColumns } from './column-detector'
|
||||
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
|
||||
import { classifyCustomer } from '../shared/classify'
|
||||
import {
|
||||
COUNTRY_CONSISTENCY_MESSAGES,
|
||||
checkCountryConsistency,
|
||||
defaultCountryForParty,
|
||||
normalizeCountryCode,
|
||||
} from '@/lib/vat/country-codes'
|
||||
import { readBestSheet } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedCustomerColumns,
|
||||
@@ -37,14 +43,6 @@ function normalizeCustomerType(value: string | null): CustomerType | null {
|
||||
: null
|
||||
}
|
||||
|
||||
function normalizeCountry(value: string | null): string {
|
||||
if (!value) return 'Sweden'
|
||||
const trimmed = value.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'Sweden'
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a customer-register file (Excel or CSV) and return structured rows.
|
||||
*
|
||||
@@ -133,7 +131,6 @@ export function parseCustomersFile(
|
||||
const countryRaw = columns.country_col !== null
|
||||
? cellOrNull(row[columns.country_col])
|
||||
: null
|
||||
const country = normalizeCountry(countryRaw)
|
||||
const vatNumber = columns.vat_number_col !== null
|
||||
? cellOrNull(row[columns.vat_number_col])
|
||||
: null
|
||||
@@ -154,10 +151,27 @@ export function parseCustomersFile(
|
||||
country: countryRaw,
|
||||
})
|
||||
|
||||
// ISO 3166-1 alpha-2 (customers.country is a code). A name the register
|
||||
// cannot map is kept as typed so the preview can show it and the row is
|
||||
// flagged. A missing country follows the type: SE for Swedish types,
|
||||
// the VAT prefix for an EU business, nothing for a non-EU business.
|
||||
const derivedCountry = countryRaw ? null : defaultCountryForParty(customerType, vatNumber)
|
||||
const country = countryRaw
|
||||
? (normalizeCountryCode(countryRaw) ?? countryRaw.trim())
|
||||
: (derivedCountry ?? 'SE')
|
||||
|
||||
const validationErrors: string[] = []
|
||||
if (email && !EMAIL_RE.test(email)) {
|
||||
validationErrors.push('Ogiltig e-postadress')
|
||||
}
|
||||
if (countryRaw && !normalizeCountryCode(countryRaw)) {
|
||||
validationErrors.push(`Okänt land: ${countryRaw.trim()} (ange landskod, t.ex. SE eller DE)`)
|
||||
} else if (!countryRaw && !derivedCountry) {
|
||||
validationErrors.push('Land saknas (ange landskod, t.ex. NO eller US)')
|
||||
} else {
|
||||
const countryIssue = checkCountryConsistency({ partyType: customerType, country, vatNumber })
|
||||
if (countryIssue) validationErrors.push(COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv)
|
||||
}
|
||||
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
|
||||
validationErrors.push('Ogiltigt org-/personnummer')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupplierType } from '@/types'
|
||||
import { detectSupplierColumns } from './column-detector'
|
||||
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
|
||||
import { classifySupplier } from '../shared/classify'
|
||||
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
import { readBestSheet } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedSupplierColumns,
|
||||
@@ -35,12 +36,14 @@ function normalizeSupplierType(value: string | null): SupplierType | null {
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 (suppliers.country is a code). A name the register
|
||||
* cannot map is kept as typed so the preview can show it; the row is then
|
||||
* flagged invalid below and the execute route refuses it.
|
||||
*/
|
||||
function normalizeCountry(value: string | null): string {
|
||||
if (!value) return 'SE'
|
||||
const trimmed = value.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'SE'
|
||||
return trimmed
|
||||
return normalizeCountryCode(value) ?? value.trim()
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: string | null): string {
|
||||
@@ -156,6 +159,9 @@ export function parseSuppliersFile(
|
||||
if (email && !EMAIL_RE.test(email)) {
|
||||
validationErrors.push('Ogiltig e-postadress')
|
||||
}
|
||||
if (countryRaw && !normalizeCountryCode(countryRaw)) {
|
||||
validationErrors.push(`Okänt land: ${countryRaw.trim()} (ange landskod, t.ex. SE eller DE)`)
|
||||
}
|
||||
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
|
||||
validationErrors.push('Ogiltigt org-/personnummer')
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
// 10% is not a Swedish momssats (ML 9 kap: 25 / 12 / 6) for any customer.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 10 }],
|
||||
@@ -149,7 +149,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
// Huvudregeln (ML 6 kap. 34 §): taxed where the buyer is established.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 10, unit: 'tim', unit_price: 1000, vat_rate: 0 }],
|
||||
@@ -170,7 +170,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
|
||||
// Widening the permitted set must not change the default: an omitted
|
||||
// vat_rate still falls back to getVatRules().rate === 0.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000 }],
|
||||
@@ -190,7 +190,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
// Stockholm hotel night invoiced to a German company. Restaurang/hotell is
|
||||
// taxed where performed (ML 6 kap. exception), so Swedish 12% applies even
|
||||
// though the buyer is an EU business. This was refused outright before.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Hotellnatt Stockholm', quantity: 2, unit: 'natt', unit_price: 1000, vat_rate: 12 }],
|
||||
@@ -254,7 +254,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
// 0% consulting (huvudregeln, reverse charge) + 12% hotel (taxed where
|
||||
// performed) on one invoice. The buyer IS liable for the consulting line,
|
||||
// so the notation is required; the 12% line still carries Swedish VAT.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The customer's country gates reverse charge (#2025). An eu_business row
|
||||
* with a validated German VAT number but country SE used to get 0% here,
|
||||
* and the periodisk sammanställning was the first thing to object, after
|
||||
* the invoice had been sent and booked on 3308.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getArticleVatRateAdoptionSet,
|
||||
getAvailableVatRates,
|
||||
getPermittedVatRates,
|
||||
getVatRules,
|
||||
isReverseChargeCustomer,
|
||||
} from '../vat-rules'
|
||||
|
||||
describe('reverse charge requires an EU country other than Sweden', () => {
|
||||
it('grants reverse charge to a validated EU business in another EU country', () => {
|
||||
expect(isReverseChargeCustomer('eu_business', true, 'DE')).toBe(true)
|
||||
expect(getVatRules('eu_business', true, 'DE')).toMatchObject({ treatment: 'reverse_charge', rate: 0, momsRuta: '39' })
|
||||
expect(getAvailableVatRates('eu_business', true, 'DE').map((r) => r.treatment)).toEqual(['reverse_charge'])
|
||||
expect(getPermittedVatRates('eu_business', true, 'DE')[0].treatment).toBe('reverse_charge')
|
||||
expect(getArticleVatRateAdoptionSet('eu_business', true, 'DE').size).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses reverse charge when the country is Sweden (#2025)', () => {
|
||||
expect(isReverseChargeCustomer('eu_business', true, 'SE')).toBe(false)
|
||||
expect(getVatRules('eu_business', true, 'SE')).toMatchObject({ treatment: 'standard_25', rate: 25, momsRuta: '05' })
|
||||
expect(getAvailableVatRates('eu_business', true, 'SE').map((r) => r.rate)).toEqual([25, 12, 6, 0])
|
||||
// The permitted set is the domestic set, not "0% first plus the Swedish rates".
|
||||
expect(getPermittedVatRates('eu_business', true, 'SE').map((r) => r.treatment)).toEqual([
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'exempt',
|
||||
])
|
||||
expect(getArticleVatRateAdoptionSet('eu_business', true, 'SE')).toEqual(new Set([25, 12, 6, 0]))
|
||||
})
|
||||
|
||||
it('keeps reverse charge for a validated number with a non-EU address', () => {
|
||||
// The VIES-validated number is the stronger evidence of an EU
|
||||
// registration: a Swiss company registered in Germany, Monaco,
|
||||
// Northern Ireland. Only Sweden refuses.
|
||||
expect(getVatRules('eu_business', true, 'CH').treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'MC').treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'GB').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('reads a legacy country name the same way as its code', () => {
|
||||
expect(getVatRules('eu_business', true, 'Sweden').treatment).toBe('standard_25')
|
||||
expect(getVatRules('eu_business', true, 'Germany').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('keeps the pre-2026-09 behaviour when the country is unknown', () => {
|
||||
expect(getVatRules('eu_business', true).treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, null).treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'Deutschland (Bayern)').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('does not touch export or domestic customers', () => {
|
||||
expect(getVatRules('non_eu_business', false, 'SE').treatment).toBe('export')
|
||||
expect(getVatRules('swedish_business', true, 'DE').treatment).toBe('standard_25')
|
||||
expect(getVatRules('eu_business', false, 'DE').treatment).toBe('standard_25')
|
||||
})
|
||||
})
|
||||
@@ -186,7 +186,7 @@ export async function buildInvoiceWriteData(params: {
|
||||
const { supabase, companyId, customer, documentType, input, existingPersonnummer } = params
|
||||
const items = input.items
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
// Gate on the PERMITTED set, not the picker default. Under huvudregeln
|
||||
// (ML 6 kap. 34 §) a service to a foreign business is taxed where the buyer
|
||||
// is established, so 0% is the default; but the ML 6 kap. exceptions taxed
|
||||
@@ -196,7 +196,7 @@ export async function buildInvoiceWriteData(params: {
|
||||
// Refusing every non-zero rate made a Stockholm hotel night or a conference
|
||||
// ticket impossible to invoice. The default is still 0% (vatRules.rate is
|
||||
// the fallback below), so a Swedish rate only lands here when set explicitly.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// VAT registration gate (defense in depth: the invoice form already hides
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { isTextLikeLine } from '@/lib/invoices/display'
|
||||
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
|
||||
import { getCountryName } from '@/lib/vat/country-codes'
|
||||
|
||||
type PdfLang = 'sv' | 'en'
|
||||
|
||||
@@ -1007,7 +1008,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text>{customer.postal_code} {customer.city}</Text>
|
||||
)}
|
||||
{customer.country && customer.country !== 'SE' && (
|
||||
<Text>{customer.country}</Text>
|
||||
<Text>{getCountryName(customer.country, lang)}</Text>
|
||||
)}
|
||||
{/* Seller-assigned kundnummer: no per-customer-type guard needed,
|
||||
it identifies the customer in the seller's own register and
|
||||
|
||||
@@ -264,7 +264,7 @@ export async function executeRecurringSchedule(
|
||||
throw new Error(`customer not found for schedule ${schedule.id}`)
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
// Gate on the PERMITTED set, not the picker default, exactly like
|
||||
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
|
||||
// (hotel/restaurang 12%, persontransport and event admission 6%,
|
||||
@@ -272,7 +272,7 @@ export async function executeRecurringSchedule(
|
||||
// foreign business customer. A monthly hotel or catering retainer to a German
|
||||
// company is such a schedule. The default is still 0% (vatRules.rate is the
|
||||
// fallback below), so a Swedish rate only lands here when the schedule set it.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// 2. Compute amounts (mirrors POST /api/invoices).
|
||||
|
||||
@@ -59,6 +59,8 @@ interface SelfBilledCustomer {
|
||||
name: string
|
||||
customer_type: string
|
||||
vat_number_validated: boolean | null
|
||||
/** ISO 3166-1 alpha-2; gates reverse charge together with the two above. */
|
||||
country?: string | null
|
||||
}
|
||||
|
||||
export interface SelfBilledSaleComputedItem {
|
||||
@@ -104,7 +106,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
// the fields used (data minimisation).
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.select('id, name, customer_type, vat_number_validated, country')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -119,6 +121,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
const vatRules = getVatRules(
|
||||
c.customer_type as Parameters<typeof getVatRules>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
c.country,
|
||||
)
|
||||
// Gate on the PERMITTED set, not the picker default, exactly like
|
||||
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
|
||||
@@ -130,6 +133,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
const permittedRates = getPermittedVatRates(
|
||||
c.customer_type as Parameters<typeof getPermittedVatRates>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
c.country,
|
||||
)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomerType, VatTreatment } from '@/types'
|
||||
import { countryPermitsReverseCharge } from '@/lib/vat/country-codes'
|
||||
|
||||
export interface VatRateOption {
|
||||
rate: number
|
||||
@@ -6,6 +7,25 @@ export interface VatRateOption {
|
||||
treatment: VatTreatment
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse charge (0%, ruta 39) needs all three: EU-business type, a VIES-
|
||||
* validated VAT number, and a country other than Sweden (a buyer
|
||||
* established here owes Swedish VAT whatever foreign number it holds).
|
||||
* `country` undefined means the caller did not have it, which keeps the
|
||||
* pre-2026-09 behaviour (type + validation only).
|
||||
*/
|
||||
export function isReverseChargeCustomer(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
customerType === 'eu_business' &&
|
||||
vatNumberValidated &&
|
||||
countryPermitsReverseCharge(country)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DEFAULT VAT rates offered for invoice line items, per customer type.
|
||||
*
|
||||
@@ -24,13 +44,20 @@ export interface VatRateOption {
|
||||
* form hides the Moms column entirely when company_settings.vat_registered is
|
||||
* false, and both the create route and the MCP commit force every line to 0%
|
||||
* (momsfri) server-side, so a non-momsregistrerad company never books output VAT.
|
||||
*
|
||||
* `country` is the customer's ISO 3166-1 alpha-2 country. Reverse charge is
|
||||
* refused when it is SE (see countryPermitsReverseCharge). An eu_business
|
||||
* row with a validated German VAT number but country SE used to get 0% here
|
||||
* and only be caught by the periodisk sammanställning after the invoice was
|
||||
* sent (#2025).
|
||||
*/
|
||||
export function getAvailableVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRateOption[] {
|
||||
// EU business with validated VAT → reverse charge, locked to 0%
|
||||
if (customerType === 'eu_business' && vatNumberValidated) {
|
||||
if (isReverseChargeCustomer(customerType, vatNumberValidated, country)) {
|
||||
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
|
||||
}
|
||||
|
||||
@@ -61,8 +88,9 @@ export function getAvailableVatRates(
|
||||
export function getArticleVatRateAdoptionSet(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): ReadonlySet<number> {
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated)
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated, country)
|
||||
return new Set(offered.length > 1 ? offered.map((r) => r.rate) : [])
|
||||
}
|
||||
|
||||
@@ -102,12 +130,13 @@ export function getArticleVatRateAdoptionSet(
|
||||
export function getPermittedVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRateOption[] {
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated)
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated, country)
|
||||
|
||||
const isForeignBusiness =
|
||||
customerType === 'non_eu_business' ||
|
||||
(customerType === 'eu_business' && vatNumberValidated)
|
||||
isReverseChargeCustomer(customerType, vatNumberValidated, country)
|
||||
if (!isForeignBusiness) {
|
||||
return offered
|
||||
}
|
||||
@@ -152,8 +181,8 @@ export interface VatRule {
|
||||
*
|
||||
* Rules:
|
||||
* - Swedish customers: 25% VAT, moms ruta 05
|
||||
* - EU business with validated VAT: 0% reverse charge, moms ruta 39
|
||||
* - EU business without validated VAT: 25% VAT, moms ruta 05
|
||||
* - EU business with validated VAT and a country other than SE: 0% reverse charge, moms ruta 39
|
||||
* - EU business without validated VAT, or with country SE: 25% VAT, moms ruta 05
|
||||
* - Non-EU business: 0% export, moms ruta 40
|
||||
*
|
||||
* Independent of the seller's VAT registration status. A non-momsregistrerad
|
||||
@@ -163,6 +192,7 @@ export interface VatRule {
|
||||
export function getVatRules(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRule {
|
||||
switch (customerType) {
|
||||
case 'individual':
|
||||
@@ -174,7 +204,7 @@ export function getVatRules(
|
||||
}
|
||||
|
||||
case 'eu_business':
|
||||
if (vatNumberValidated) {
|
||||
if (isReverseChargeCustomer(customerType, vatNumberValidated, country)) {
|
||||
return {
|
||||
treatment: 'reverse_charge',
|
||||
rate: 0,
|
||||
@@ -182,7 +212,8 @@ export function getVatRules(
|
||||
reverseChargeText: 'Omvänd skattskyldighet / Reverse charge - VAT to be accounted for by the recipient as per Article 196, Council Directive 2006/112/EC',
|
||||
}
|
||||
}
|
||||
// EU business without validated VAT number must be charged Swedish VAT
|
||||
// EU business without validated VAT number, or one whose country is
|
||||
// Sweden, must be charged Swedish VAT
|
||||
return {
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
|
||||
@@ -96,6 +96,8 @@ function queueFor(
|
||||
const euCustomer = makeCustomer({
|
||||
id: 'cust-1',
|
||||
customer_type: 'eu_business',
|
||||
country: 'DE',
|
||||
vat_number: 'DE811234567',
|
||||
vat_number_validated: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
|
||||
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
|
||||
import {
|
||||
COUNTRY_CONSISTENCY_MESSAGES,
|
||||
checkCountryConsistency,
|
||||
defaultCountryForParty,
|
||||
normalizeCountryCode,
|
||||
} from '@/lib/vat/country-codes'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
resolveSupplierInvoiceExchangeRate,
|
||||
@@ -471,6 +477,31 @@ async function commitCreateCustomer(
|
||||
typeof params.payment_terms === 'number' ? params.payment_terms : undefined,
|
||||
)
|
||||
|
||||
// Country: ISO 3166-1 alpha-2, consistent with the type and VAT prefix.
|
||||
// Staging already checked this; re-checked here so a tampered or
|
||||
// pre-2026-09 pending_operations row cannot write a name or a
|
||||
// contradiction (#2025, #2028).
|
||||
const countryParam = typeof params.country === 'string' ? params.country.trim() : ''
|
||||
const country = countryParam
|
||||
? normalizeCountryCode(countryParam)
|
||||
: defaultCountryForParty(params.customer_type as string, (params.vat_number as string) || null)
|
||||
if (!country) {
|
||||
return {
|
||||
error: countryParam
|
||||
? `country "${countryParam}" is not an ISO 3166-1 alpha-2 code or a known country name`
|
||||
: 'country is required for this customer type',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
const countryIssue = checkCountryConsistency({
|
||||
partyType: params.customer_type as string,
|
||||
country,
|
||||
vatNumber: (params.vat_number as string) || null,
|
||||
})
|
||||
if (countryIssue) {
|
||||
return { error: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv, status: 400 }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
@@ -487,7 +518,7 @@ async function commitCreateCustomer(
|
||||
address_line1: (params.address as string) || null,
|
||||
postal_code: (params.postal_code as string) || null,
|
||||
city: (params.city as string) || null,
|
||||
country: (params.country as string) || 'Sweden',
|
||||
country,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -536,7 +567,7 @@ async function commitUpdateCustomer(
|
||||
const { customer_id: customerId, changes } = validated
|
||||
const { data: current, error: currentError } = await supabase
|
||||
.from('customers')
|
||||
.select('customer_type')
|
||||
.select('customer_type, country, vat_number')
|
||||
.eq('id', customerId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -544,6 +575,22 @@ async function commitUpdateCustomer(
|
||||
if (currentError) return { error: currentError.message, status: 500 }
|
||||
if (!current) return { error: 'Customer not found', status: 404 }
|
||||
|
||||
// Country vs type vs VAT prefix on the row as it will end up (same check
|
||||
// as staging; repeated here as the tamper gate), only when one of the
|
||||
// three is part of the update.
|
||||
const countryRuleTouched =
|
||||
changes.customer_type !== undefined || changes.country !== undefined || changes.vat_number !== undefined
|
||||
const countryIssue = countryRuleTouched
|
||||
? checkCountryConsistency({
|
||||
partyType: changes.customer_type ?? current.customer_type,
|
||||
country: changes.country ?? current.country,
|
||||
vatNumber: changes.vat_number ?? current.vat_number,
|
||||
})
|
||||
: null
|
||||
if (countryIssue) {
|
||||
return { error: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv, status: 400 }
|
||||
}
|
||||
|
||||
// personal_number never travels in plaintext: staging validated the input
|
||||
// and stored AES-256-GCM ciphertext under personal_number_encrypted (see
|
||||
// CustomerChangesSchema). Map it onto the customers.personal_number column
|
||||
@@ -1901,14 +1948,14 @@ async function commitCreateInvoice(
|
||||
return { error: 'Customer not found: they may have been deleted.', status: 404 }
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
// Gate on the PERMITTED set, not the picker default, exactly like
|
||||
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
|
||||
// (hotel/restaurang 12%, persontransport and event admission 6%,
|
||||
// fastighetstjänst and korttidsuthyrning 25%) carry Swedish VAT even to a
|
||||
// foreign business customer. The default is still 0% (vatRules.rate is the
|
||||
// fallback below), so a Swedish rate only lands here when staged explicitly.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// VAT registration gate (mirrors app/api/invoices/route.ts). A
|
||||
|
||||
@@ -453,3 +453,36 @@ describe('reconcilePsAgainstVatDeclaration', () => {
|
||||
expect(reconciled.reconciliation.matches).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy country names on customers (#2028)', () => {
|
||||
it('reads a stored country name as its ISO code: no false warnings, correct CSV country', async () => {
|
||||
const legacy = invDE()
|
||||
legacy.customer!.country = 'Germany'
|
||||
results = [
|
||||
{ data: [entryEU('inv-de')], error: null },
|
||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
||||
{ data: [legacy], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings).toEqual([])
|
||||
expect(report.rows).toHaveLength(1)
|
||||
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 15000 })
|
||||
})
|
||||
|
||||
it('still names an unmapped country in the warning', async () => {
|
||||
const legacy = invDE()
|
||||
legacy.customer!.country = 'Atlantis'
|
||||
results = [
|
||||
{ data: [entryEU('inv-de')], error: null },
|
||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
||||
{ data: [legacy], error: null },
|
||||
]
|
||||
|
||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||
|
||||
expect(report.warnings.map((w) => w.code)).toContain('NON_EU_COUNTRY_ON_EU_ACCOUNT')
|
||||
expect(report.warnings.find((w) => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')?.message).toContain('ATLANTIS')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
|
||||
import { calculateVatDeclaration } from './vat-declaration'
|
||||
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
|
||||
/**
|
||||
* Periodisk sammanställning (EC Sales List / SKV 5740).
|
||||
@@ -263,7 +264,10 @@ export async function generatePeriodiskSammanstallning(
|
||||
continue
|
||||
}
|
||||
|
||||
const isoCountry = (customer.country ?? '').trim().toUpperCase()
|
||||
// Rows written before 2026-09 may still hold a country name the
|
||||
// backfill could not map; a name the register knows becomes its code, an
|
||||
// unknown one is kept as typed so the warning names it.
|
||||
const isoCountry = normalizeCountryCode(customer.country) ?? (customer.country ?? '').trim().toUpperCase()
|
||||
const vatCountry = isoCountry ? toVatCountryCode(isoCountry) : ''
|
||||
const rawVat = customer.vat_number ?? ''
|
||||
const normalizedVat = normalizeVatNumber(rawVat)
|
||||
|
||||
@@ -40,11 +40,11 @@ export type NormalizeLinesResult =
|
||||
|
||||
export function normalizeSalesOrderLines(
|
||||
items: SalesOrderItemInput[],
|
||||
customer: Pick<Customer, 'customer_type' | 'vat_number_validated'>,
|
||||
customer: Pick<Customer, 'customer_type' | 'vat_number_validated'> & { country?: string | null },
|
||||
): NormalizeLinesResult {
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowed = new Set(
|
||||
getPermittedVatRates(customer.customer_type, customer.vat_number_validated).map((r) => r.rate),
|
||||
getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country).map((r) => r.rate),
|
||||
)
|
||||
|
||||
const rows: SalesOrderLineRow[] = []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The backfill in migration 20260903170000 maps country names with a SQL
|
||||
* function that carries its own copy of the name table. This test holds the
|
||||
* two copies to each other: every name the SQL knows must map to the same
|
||||
* code in TypeScript, and every name TypeScript knows must be in the SQL.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { listKnownCountryNames, normalizeCountryCode } from '../country-codes'
|
||||
|
||||
const MIGRATION = join(
|
||||
process.cwd(),
|
||||
'supabase/migrations/20260903170000_customer_supplier_country_iso.sql',
|
||||
)
|
||||
|
||||
function sqlNameTable(): Map<string, string> {
|
||||
const sql = readFileSync(MIGRATION, 'utf-8')
|
||||
const table = new Map<string, string>()
|
||||
const re = /^\s+\('((?:[^']|'')+)', '([A-Z]{2})'\),?$/gm
|
||||
for (const match of sql.matchAll(re)) {
|
||||
table.set(match[1].replace(/''/g, "'"), match[2])
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
describe('normalize_country_code() SQL table vs normalizeCountryCode()', () => {
|
||||
it('maps every SQL name to the same code in TypeScript', () => {
|
||||
const sql = sqlNameTable()
|
||||
expect(sql.size).toBeGreaterThan(100)
|
||||
for (const [name, code] of sql) {
|
||||
expect(normalizeCountryCode(name), name).toBe(code)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries every TypeScript name in the SQL table', () => {
|
||||
const sql = sqlNameTable()
|
||||
const ts = listKnownCountryNames()
|
||||
const missing = ts.filter(([name, code]) => sql.get(name) !== code)
|
||||
expect(missing).toEqual([])
|
||||
expect(sql.size).toBe(ts.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
COUNTRY_OPTIONS,
|
||||
checkCountryConsistency,
|
||||
countryPermitsReverseCharge,
|
||||
defaultCountryForParty,
|
||||
getCountryName,
|
||||
getCountryOptions,
|
||||
isCountryCode,
|
||||
isEuTradeVatPrefix,
|
||||
normalizeCountryCode,
|
||||
vatNumberCountryPrefix,
|
||||
vatPrefixForCountry,
|
||||
} from '../country-codes'
|
||||
import { EU_COUNTRIES } from '../eu-countries'
|
||||
|
||||
describe('normalizeCountryCode', () => {
|
||||
it('passes a well-formed code through, uppercased and trimmed', () => {
|
||||
expect(normalizeCountryCode('SE')).toBe('SE')
|
||||
expect(normalizeCountryCode('de')).toBe('DE')
|
||||
expect(normalizeCountryCode(' no ')).toBe('NO')
|
||||
})
|
||||
|
||||
it('maps the VIES spelling of Greece and the customary UK', () => {
|
||||
expect(normalizeCountryCode('EL')).toBe('GR')
|
||||
expect(normalizeCountryCode('UK')).toBe('GB')
|
||||
})
|
||||
|
||||
it('maps the names the customer form and the v1 API used to write (#2028)', () => {
|
||||
expect(normalizeCountryCode('Sweden')).toBe('SE')
|
||||
expect(normalizeCountryCode('Sverige')).toBe('SE')
|
||||
expect(normalizeCountryCode('Germany')).toBe('DE')
|
||||
expect(normalizeCountryCode('GERMANY')).toBe('DE')
|
||||
expect(normalizeCountryCode('Tyskland')).toBe('DE')
|
||||
expect(normalizeCountryCode('Deutschland')).toBe('DE')
|
||||
expect(normalizeCountryCode('Nederländerna')).toBe('NL')
|
||||
expect(normalizeCountryCode('United States of America')).toBe('US')
|
||||
expect(normalizeCountryCode('U.S.A.')).toBe('US')
|
||||
expect(normalizeCountryCode('Norway')).toBe('NO')
|
||||
})
|
||||
|
||||
it('maps every Swedish and English name in the option list to its code', () => {
|
||||
for (const option of COUNTRY_OPTIONS) {
|
||||
expect(normalizeCountryCode(option.name)).toBe(option.code)
|
||||
expect(normalizeCountryCode(option.nameEn)).toBe(option.code)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for empty input and anything it does not know', () => {
|
||||
expect(normalizeCountryCode(null)).toBeNull()
|
||||
expect(normalizeCountryCode(undefined)).toBeNull()
|
||||
expect(normalizeCountryCode('')).toBeNull()
|
||||
expect(normalizeCountryCode(' ')).toBeNull()
|
||||
expect(normalizeCountryCode('Atlantis')).toBeNull()
|
||||
expect(normalizeCountryCode('SWE')).toBeNull()
|
||||
expect(normalizeCountryCode('S')).toBeNull()
|
||||
expect(normalizeCountryCode('SE.')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCountryCode / getCountryName / getCountryOptions', () => {
|
||||
it('recognises only an uppercase alpha-2 as already normalised', () => {
|
||||
expect(isCountryCode('SE')).toBe(true)
|
||||
expect(isCountryCode('se')).toBe(false)
|
||||
expect(isCountryCode('Sweden')).toBe(false)
|
||||
expect(isCountryCode(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a code in the requested language and falls back to the value', () => {
|
||||
expect(getCountryName('DE', 'sv')).toBe('Tyskland')
|
||||
expect(getCountryName('DE', 'en')).toBe('Germany')
|
||||
expect(getCountryName('Germany', 'sv')).toBe('Tyskland')
|
||||
expect(getCountryName('AQ')).toBe('AQ')
|
||||
expect(getCountryName('Atlantis')).toBe('Atlantis')
|
||||
expect(getCountryName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('lists every option once with Sweden first, sorted by the locale name', () => {
|
||||
const sv = getCountryOptions('sv')
|
||||
expect(sv[0].code).toBe('SE')
|
||||
expect(new Set(sv.map((o) => o.code)).size).toBe(COUNTRY_OPTIONS.length)
|
||||
const rest = sv.slice(1).map((o) => o.name)
|
||||
expect(rest).toEqual([...rest].sort(new Intl.Collator('sv').compare))
|
||||
expect(getCountryOptions('en')[0].code).toBe('SE')
|
||||
})
|
||||
|
||||
it('offers all 27 EU members', () => {
|
||||
const codes = new Set(COUNTRY_OPTIONS.map((o) => o.code))
|
||||
for (const eu of EU_COUNTRIES) expect(codes.has(eu.code)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vatNumberCountryPrefix', () => {
|
||||
it('reads the leading two letters, ignoring spaces, dots and dashes', () => {
|
||||
expect(vatNumberCountryPrefix('DE811234567')).toBe('DE')
|
||||
expect(vatNumberCountryPrefix(' de 811 234 567')).toBe('DE')
|
||||
expect(vatNumberCountryPrefix('ATU12345678')).toBe('AT')
|
||||
expect(vatNumberCountryPrefix('EL123456789')).toBe('EL')
|
||||
})
|
||||
|
||||
it('is null without a letter prefix', () => {
|
||||
expect(vatNumberCountryPrefix('811234567')).toBeNull()
|
||||
expect(vatNumberCountryPrefix('')).toBeNull()
|
||||
expect(vatNumberCountryPrefix(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vatPrefixForCountry / isEuTradeVatPrefix', () => {
|
||||
it('maps Greece to EL and Monaco to FR, and knows XI as an EU-trade prefix', () => {
|
||||
expect(vatPrefixForCountry('GR')).toBe('EL')
|
||||
expect(vatPrefixForCountry('MC')).toBe('FR')
|
||||
expect(vatPrefixForCountry('DE')).toBe('DE')
|
||||
expect(isEuTradeVatPrefix('DE')).toBe(true)
|
||||
expect(isEuTradeVatPrefix('EL')).toBe(true)
|
||||
expect(isEuTradeVatPrefix('XI')).toBe(true)
|
||||
expect(isEuTradeVatPrefix('SE')).toBe(false)
|
||||
expect(isEuTradeVatPrefix('GB')).toBe(false)
|
||||
expect(isEuTradeVatPrefix('CH')).toBe(false)
|
||||
expect(isEuTradeVatPrefix(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultCountryForParty', () => {
|
||||
it('is SE for Swedish types', () => {
|
||||
expect(defaultCountryForParty('swedish_business')).toBe('SE')
|
||||
expect(defaultCountryForParty('individual')).toBe('SE')
|
||||
})
|
||||
|
||||
it('derives an EU business country from the VAT prefix, EL included', () => {
|
||||
expect(defaultCountryForParty('eu_business', 'DE811234567')).toBe('DE')
|
||||
expect(defaultCountryForParty('eu_business', 'EL123456789')).toBe('GR')
|
||||
})
|
||||
|
||||
it('has nothing to derive for an EU business without a usable prefix', () => {
|
||||
expect(defaultCountryForParty('eu_business', '811234567')).toBeNull()
|
||||
expect(defaultCountryForParty('eu_business', 'SE556677889901')).toBeNull()
|
||||
expect(defaultCountryForParty('eu_business', 'CHE123456789')).toBeNull()
|
||||
expect(defaultCountryForParty('eu_business')).toBeNull()
|
||||
})
|
||||
|
||||
it('never guesses for a non-EU business', () => {
|
||||
expect(defaultCountryForParty('non_eu_business')).toBeNull()
|
||||
expect(defaultCountryForParty('non_eu_business', 'GB123456789')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCountryConsistency', () => {
|
||||
it('accepts the shapes that agree', () => {
|
||||
expect(checkCountryConsistency({ partyType: 'swedish_business', country: 'SE' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'individual', country: 'SE' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'individual', country: 'NO' })).toBeNull()
|
||||
expect(
|
||||
checkCountryConsistency({ partyType: 'eu_business', country: 'DE', vatNumber: 'DE811234567' }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
checkCountryConsistency({ partyType: 'eu_business', country: 'GR', vatNumber: 'EL123456789' }),
|
||||
).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'DE', vatNumber: '811234567' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'DE' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'non_eu_business', country: 'NO' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'non_eu_business', country: 'US' })).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses the #2025 shape: EU business with land Sverige', () => {
|
||||
expect(
|
||||
checkCountryConsistency({ partyType: 'eu_business', country: 'SE', vatNumber: 'DE811234567' }),
|
||||
).toBe('EU_BUSINESS_COUNTRY_IS_SE')
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'Sweden' })).toBe(
|
||||
'EU_BUSINESS_COUNTRY_IS_SE',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts an EU VAT registration outside the EU and Monaco under the French prefix', () => {
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'CH', vatNumber: 'DE811234567' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'GB', vatNumber: 'XI123456789' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'MC', vatNumber: 'FR12345678901' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'MC' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'MC', vatNumber: 'DE811234567' })).toBe(
|
||||
'VAT_PREFIX_COUNTRY_MISMATCH',
|
||||
)
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'GB', vatNumber: 'GB123456789' })).toBe(
|
||||
'EU_BUSINESS_REQUIRES_EU_COUNTRY',
|
||||
)
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'CH', vatNumber: 'SE556677889901' })).toBe(
|
||||
'EU_BUSINESS_REQUIRES_EU_COUNTRY',
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses an EU business outside the EU without an EU registration, and a non-EU business inside it', () => {
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: 'NO' })).toBe(
|
||||
'EU_BUSINESS_REQUIRES_EU_COUNTRY',
|
||||
)
|
||||
expect(checkCountryConsistency({ partyType: 'non_eu_business', country: 'DE' })).toBe(
|
||||
'NON_EU_BUSINESS_REQUIRES_NON_EU_COUNTRY',
|
||||
)
|
||||
expect(checkCountryConsistency({ partyType: 'non_eu_business', country: 'SE' })).toBe(
|
||||
'NON_EU_BUSINESS_REQUIRES_NON_EU_COUNTRY',
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a Swedish business abroad', () => {
|
||||
expect(checkCountryConsistency({ partyType: 'swedish_business', country: 'DE' })).toBe(
|
||||
'SWEDISH_BUSINESS_REQUIRES_SE',
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a VAT prefix that names another country than the row', () => {
|
||||
expect(
|
||||
checkCountryConsistency({ partyType: 'eu_business', country: 'FR', vatNumber: 'DE811234567' }),
|
||||
).toBe('VAT_PREFIX_COUNTRY_MISMATCH')
|
||||
expect(
|
||||
checkCountryConsistency({ partyType: 'eu_business', country: 'GR', vatNumber: 'GR123456789' }),
|
||||
).toBe('VAT_PREFIX_COUNTRY_MISMATCH')
|
||||
})
|
||||
|
||||
it('has nothing to say when the country is missing or unmapped', () => {
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: null })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'eu_business', country: '' })).toBeNull()
|
||||
expect(checkCountryConsistency({ partyType: 'swedish_business', country: 'Atlantis' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryPermitsReverseCharge', () => {
|
||||
it('allows any country other than Sweden, by code or by legacy name', () => {
|
||||
expect(countryPermitsReverseCharge('DE')).toBe(true)
|
||||
expect(countryPermitsReverseCharge('Germany')).toBe(true)
|
||||
expect(countryPermitsReverseCharge('gr')).toBe(true)
|
||||
// A VIES-validated number outweighs a non-EU address (Swiss company
|
||||
// registered in Germany, Monaco, Northern Ireland).
|
||||
expect(countryPermitsReverseCharge('CH')).toBe(true)
|
||||
expect(countryPermitsReverseCharge('MC')).toBe(true)
|
||||
expect(countryPermitsReverseCharge('GB')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses Sweden only (#2025)', () => {
|
||||
expect(countryPermitsReverseCharge('SE')).toBe(false)
|
||||
expect(countryPermitsReverseCharge('Sweden')).toBe(false)
|
||||
expect(countryPermitsReverseCharge('Sverige')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not block on an unknown country: that is the consistency check\'s job', () => {
|
||||
expect(countryPermitsReverseCharge(null)).toBe(true)
|
||||
expect(countryPermitsReverseCharge(undefined)).toBe(true)
|
||||
expect(countryPermitsReverseCharge('Deutschland (Bayern)')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
Reference in New Issue
Block a user