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

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

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

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

Closes #2025, closes #2028

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

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

Skeptic and CI findings on #2241, one pass:

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 18:09:46 +02:00
committed by GitHub
parent 80b87c55fc
commit 3918ff6620
48 changed files with 2288 additions and 97 deletions
+1
View File
@@ -1527,4 +1527,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903170000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
[2026-09-03] AGI kvittens cron: dropped the apigw_config bucket (#963) and its warn-once suppression for ACCESS_DENIED (#2226). The bucket existed because the APIGW client was known to lack the AGI hantera subscription in Utvecklarportalen; with that subscription expected in place, a gateway refusal is a regression and belongs in the ordinary error path (error level, generic 'error' status) rather than a status that hides it as a known gap. Same pass: the connector-mode gateway-refusal message names the connector operator by host instead of "kontakta supporten", because hosted is itself a Connect installation for the canary companies and "support" no longer says whose. Merging before the portal subscription is active means the 15-minute cron logs at error level per pending declaration until it is.
+2 -1
View File
@@ -23,6 +23,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatDate } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { getCountryName } from '@/lib/vat/country-codes'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
@@ -272,7 +273,7 @@ export default function CustomerDetailPage({
{(customer.postal_code || customer.city) && (
<p>{[customer.postal_code, customer.city].filter(Boolean).join(' ')}</p>
)}
{customer.country && <p>{customer.country}</p>}
{customer.country && <p>{getCountryName(customer.country, errorLocale)}</p>}
</div>
) : (
<DefEmpty />
+2 -1
View File
@@ -87,6 +87,7 @@ import type { Invoice, InvoiceItem, InvoiceStatus, InvoiceReminder, InvoiceDocum
import type { InvoiceWithRelations } from '@/components/invoices/types'
import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import { getCountryName } from '@/lib/vat/country-codes'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Minimized Peppol delivery projection from GET /api/invoices/[id]/peppol/deliveries. */
@@ -1918,7 +1919,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{customer.address_line2 && <p>{customer.address_line2}</p>}
<p>
{[customer.postal_code, customer.city].filter(Boolean).join(' ')}
{customer.country && customer.country !== 'SE' && `, ${customer.country}`}
{customer.country && customer.country !== 'SE' && `, ${getCountryName(customer.country, locale === 'en' ? 'en' : 'sv')}`}
</p>
</div>
) : (
+25 -1
View File
@@ -13,6 +13,7 @@ import {
} from '@/lib/customers/personal-number-shape'
import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { COUNTRY_CONSISTENCY_MESSAGES, checkCountryConsistency } from '@/lib/vat/country-codes'
export const GET = withRouteContext(
'customer.get',
@@ -66,7 +67,7 @@ export const PATCH = withRouteContext(
const { data: existing, error: existingError } = await supabase
.from('customers')
.select('id, customer_type')
.select('id, customer_type, country, vat_number')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -129,6 +130,29 @@ export const PATCH = withRouteContext(
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_CONFLICT', opLog, { requestId })
}
// Country vs type vs VAT prefix on the row as it will END UP (#2025): a
// type change alone can make the stored country wrong, and a country
// change alone can contradict the stored VAT number. Judged only when
// one of the three is in the body: a legacy row that is already
// contradictory must still be able to change its email.
const countryRuleTouched =
body.customer_type !== undefined || body.country !== undefined || body.vat_number !== undefined
const countryIssue = countryRuleTouched
? checkCountryConsistency({
partyType: effectiveType,
country: body.country ?? existing.country,
vatNumber: body.vat_number ?? existing.vat_number,
})
: null
if (countryIssue) {
return errorResponseFromCode('CUSTOMER_COUNTRY_MISMATCH', opLog, {
requestId,
messageSv: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv,
messageEn: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en,
details: { issue: countryIssue, field: 'country' },
})
}
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
+244
View File
@@ -0,0 +1,244 @@
/**
* customers.country is ISO 3166-1 alpha-2 and must agree with the customer
* type and the VAT prefix (#2025, #2028) on POST /api/customers and
* PATCH /api/customers/[id].
*
* Same harness as customer-number.test.ts: the routes run through the real
* withRouteContext wrapper with a hand-rolled Supabase mock that records
* insert/update payloads and answers every query with `queryResult`.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(queryResult)
}
return (...args: unknown[]) => {
if (prop === 'insert') captured.insert.push(args[0])
if (prop === 'update') captured.update.push(args[0])
return buildChain()
}
},
},
)
const supabase = {
from: vi.fn(() => buildChain()),
rpc: vi.fn(() => buildChain()),
}
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Never reach VIES from a unit test.
vi.mock('@/lib/vat/vies-client', () => ({
validateVatNumber: vi.fn().mockResolvedValue({ valid: false }),
}))
import { POST } from '../route'
import { PATCH } from '../[id]/route'
type CustomerRow = { country?: string }
const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
captured.insert.length = 0
captured.update.length = 0
queryResult = { data: null, error: null }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('country on POST /api/customers', () => {
it('stores a country name as its ISO code (#2028)', async () => {
queryResult = { data: { id: CUSTOMER_ID, name: 'Muster Handels GmbH', country: 'DE' }, error: null }
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
country: 'Germany',
vat_number: 'DE811234567',
},
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(200)
expect((captured.insert[0] as CustomerRow).country).toBe('DE')
})
it('derives the EU country from the VAT prefix when none is given', async () => {
queryResult = { data: { id: CUSTOMER_ID, name: 'Muster Handels GmbH', country: 'DE' }, error: null }
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Muster Handels GmbH', customer_type: 'eu_business', vat_number: 'DE811234567' },
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(200)
expect((captured.insert[0] as CustomerRow).country).toBe('DE')
})
it('defaults a Swedish business to SE, never to the name Sweden', async () => {
queryResult = { data: { id: CUSTOMER_ID, name: 'Acme AB', country: 'SE' }, error: null }
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Acme AB', customer_type: 'swedish_business' },
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(200)
expect((captured.insert[0] as CustomerRow).country).toBe('SE')
})
it('rejects an EU business with land Sverige (#2025)', async () => {
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
country: 'Sverige',
vat_number: 'DE811234567',
},
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(400)
const { body } = await parseJsonResponse<{ error: unknown }>(response)
expect(JSON.stringify(body.error)).toMatch(/country/)
expect(captured.insert).toHaveLength(0)
})
it('rejects a VAT prefix that names another country than the row', async () => {
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Muster', customer_type: 'eu_business', country: 'FR', vat_number: 'DE811234567' },
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('rejects a country it cannot read as a code', async () => {
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Acme AB', customer_type: 'swedish_business', country: 'Atlantis' },
})
const response = await POST(request, { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
})
describe('country on PATCH /api/customers/[id]', () => {
const params = { params: Promise.resolve({ id: CUSTOMER_ID }) }
it('normalises a country name before writing it', async () => {
queryResult = {
data: { id: CUSTOMER_ID, customer_type: 'swedish_business', country: 'SE', vat_number: null },
error: null,
}
const request = createMockRequest(`/api/customers/${CUSTOMER_ID}`, {
method: 'PATCH',
body: { country: 'Sweden' },
})
const response = await PATCH(request, params)
expect(response.status).toBe(200)
expect((captured.update[0] as CustomerRow).country).toBe('SE')
})
it('refuses a type change that contradicts the stored country', async () => {
queryResult = {
data: { id: CUSTOMER_ID, customer_type: 'swedish_business', country: 'SE', vat_number: null },
error: null,
}
const request = createMockRequest(`/api/customers/${CUSTOMER_ID}`, {
method: 'PATCH',
body: { customer_type: 'eu_business' },
})
const response = await PATCH(request, params)
expect(response.status).toBe(400)
const { body } = await parseJsonResponse<{ error: { code: string; details?: { issue?: string } } }>(response)
expect(body.error.code).toBe('CUSTOMER_COUNTRY_MISMATCH')
expect(body.error.details?.issue).toBe('EU_BUSINESS_COUNTRY_IS_SE')
expect(captured.update).toHaveLength(0)
})
it('refuses a country change that contradicts the stored VAT prefix', async () => {
queryResult = {
data: { id: CUSTOMER_ID, customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567' },
error: null,
}
const request = createMockRequest(`/api/customers/${CUSTOMER_ID}`, {
method: 'PATCH',
body: { country: 'Frankrike' },
})
const response = await PATCH(request, params)
expect(response.status).toBe(400)
const { body } = await parseJsonResponse<{ error: { code: string; details?: { issue?: string } } }>(response)
expect(body.error.code).toBe('CUSTOMER_COUNTRY_MISMATCH')
expect(body.error.details?.issue).toBe('VAT_PREFIX_COUNTRY_MISMATCH')
expect(captured.update).toHaveLength(0)
})
it('lets a contradictory legacy row change unrelated fields', async () => {
queryResult = {
data: { id: CUSTOMER_ID, customer_type: 'eu_business', country: 'SE', vat_number: 'DE811234567' },
error: null,
}
const request = createMockRequest(`/api/customers/${CUSTOMER_ID}`, {
method: 'PATCH',
body: { email: 'new@example.test' },
})
const response = await PATCH(request, params)
expect(response.status).toBe(200)
expect(captured.update).toHaveLength(1)
})
it('accepts a country and type changed together into a consistent row', async () => {
queryResult = {
data: { id: CUSTOMER_ID, customer_type: 'swedish_business', country: 'SE', vat_number: null },
error: null,
}
const request = createMockRequest(`/api/customers/${CUSTOMER_ID}`, {
method: 'PATCH',
body: { customer_type: 'eu_business', country: 'de', vat_number: 'DE811234567' },
})
const response = await PATCH(request, params)
expect(response.status).toBe(200)
expect((captured.update[0] as CustomerRow).country).toBe('DE')
})
})
+1 -1
View File
@@ -87,7 +87,7 @@ export const POST = withRouteContext(
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country || 'Sweden',
country: body.country ?? 'SE',
org_number: body.org_number,
vat_number: body.vat_number,
personal_number: encryptCustomerPersonalNumber(body.personal_number),
+1 -1
View File
@@ -148,7 +148,7 @@ export const POST = withRouteContext(
address_line2: row.address_line2,
postal_code: row.postal_code,
city: row.city,
country: row.country || 'Sweden',
country: row.country || 'SE',
org_number: row.org_number,
vat_number: row.vat_number,
default_payment_terms: row.default_payment_terms || 30,
+1 -1
View File
@@ -202,7 +202,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, {
}
// VAT rules are customer-type-driven and only know the customer side.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
const isDeliveryNote = docType === 'delivery_note'
+1 -1
View File
@@ -59,7 +59,7 @@ export const POST = withRouteContext(
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country || 'SE',
country: body.country ?? 'SE',
org_number: body.org_number,
vat_number: body.vat_number,
bankgiro: body.bankgiro,
@@ -22,6 +22,14 @@ import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/l
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { COUNTRY_CONSISTENCY_MESSAGES, checkCountryConsistency } from '@/lib/vat/country-codes'
/** The stored fields the country-vs-type rule and the personnummer guards read. */
interface ExistingCountryRow {
customer_type?: string
country?: string | null
vat_number?: string | null
}
import {
encryptCustomerPersonalNumber,
maskCustomerRow,
@@ -105,7 +113,7 @@ registerEndpoint({
org_number: '556677-8899',
vat_number: 'SE556677889901',
vat_number_validated: true,
country: 'Sweden',
country: 'SE',
default_payment_terms: 30,
archived_at: null,
created_at: '2025-04-12T08:30:00Z',
@@ -304,21 +312,53 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
const personalNumberSubmitted =
body.personal_number !== undefined && !isMaskedPersonalNumber(body.personal_number)
// The individual-only rule for personal_number and the personnummer
// guard on org_number both depend on the customer_type the row will
// have after the update; read the stored type when the body is silent.
// The individual-only rule for personal_number, the personnummer guard
// on org_number and the country-vs-type check all depend on the row as
// it will be after the update; read the stored values when the body
// touches any of the fields involved.
let effectiveType: string | undefined = body.customer_type
let existing: ExistingCountryRow | null = null
if (
effectiveType === undefined &&
((personalNumberSubmitted && body.personal_number) || body.org_number)
(personalNumberSubmitted && body.personal_number)
|| body.org_number
|| body.customer_type !== undefined
|| body.country !== undefined
|| body.vat_number !== undefined
) {
const { data: existing } = await ctx.supabase
const { data } = await ctx.supabase
.from('customers')
.select('customer_type')
.select('customer_type, country, vat_number')
.eq('company_id', ctx.companyId!)
.eq('id', customerId)
.maybeSingle()
effectiveType = (existing as { customer_type?: string } | null)?.customer_type
existing = data as ExistingCountryRow | null
effectiveType ??= existing?.customer_type
}
// Country vs type vs VAT prefix on the row as it will END UP (#2025): a
// type change alone can make the stored country wrong, and a country
// change alone can contradict the stored VAT number. Judged only when one
// of the three is in the body, so a contradictory legacy row can still
// change its email.
const countryRuleTouched =
body.customer_type !== undefined || body.country !== undefined || body.vat_number !== undefined
if (countryRuleTouched && existing && effectiveType) {
const countryIssue = checkCountryConsistency({
partyType: effectiveType,
country: body.country ?? existing.country,
vatNumber: body.vat_number ?? existing.vat_number,
})
if (countryIssue) {
return v1ErrorResponseFromCode('CUSTOMER_COUNTRY_MISMATCH', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'country',
issue: countryIssue,
message_sv: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv,
message_en: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en,
},
})
}
}
if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') {
@@ -1273,3 +1273,189 @@ describe('personnummer submitted as org_number on an individual (v1)', () => {
expect(supabaseMock.captured.update).toHaveLength(0)
})
})
// ------------------------------------------------------------------
// country: ISO 3166-1 alpha-2, consistent with customer_type (#2025, #2028)
// ------------------------------------------------------------------
describe('country on POST /api/v1/companies/:companyId/customers', () => {
it('stores a country name as its ISO code (#2028)', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_type: 'eu_business', country: 'DE' }, error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
country: 'Germany',
vat_number: 'DE811234567',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
expect((client.captured.insert[0] as { country: string }).country).toBe('DE')
})
it('derives the EU country from the VAT prefix when none is given', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, customer_type: 'eu_business', country: 'DE' }, error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
vat_number: 'DE811234567',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
expect((client.captured.insert[0] as { country: string }).country).toBe('DE')
})
it('previews SE for a Swedish business in dry-run, never the name Sweden', async () => {
withWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?dry_run=true`, {
name: 'Acme AB',
customer_type: 'swedish_business',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.country).toBe('SE')
})
it('rejects an EU business with land Sverige (#2025)', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
country: 'SE',
vat_number: 'DE811234567',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(JSON.stringify(body.error)).toMatch(/country/)
// Only the idempotency-key row may have been written, never the customer.
expect(client.captured.insert.filter((row) => 'customer_type' in (row as object))).toHaveLength(0)
})
it('rejects a country it cannot read as a code', async () => {
withWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await createCustomer(
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
name: 'Acme AB',
customer_type: 'swedish_business',
country: 'Atlantis',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('country on PATCH /api/v1/companies/:companyId/customers/:id', () => {
it('normalises a country name before writing it', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, country: 'SE' }, error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await updateCustomer(
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
country: 'Sverige',
}),
detailParams(COMPANY_ID, CUSTOMER_ID),
)
expect(res.status).toBe(200)
expect((client.captured.update[0] as { country: string }).country).toBe('SE')
})
it('refuses a type change that contradicts the stored country', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: { ...SAMPLE_CUSTOMER, country: 'SE' }, error: null },
})
mockServiceClient.mockReturnValue(client)
const res = await updateCustomer(
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
customer_type: 'eu_business',
}),
detailParams(COMPANY_ID, CUSTOMER_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('CUSTOMER_COUNTRY_MISMATCH')
expect(body.error.details.issue).toBe('EU_BUSINESS_COUNTRY_IS_SE')
expect(client.captured.update).toHaveLength(0)
})
it('refuses a country change that contradicts the stored VAT prefix', async () => {
withWriteScope()
const client = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: {
data: { ...SAMPLE_CUSTOMER, customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567' },
error: null,
},
})
mockServiceClient.mockReturnValue(client)
const res = await updateCustomer(
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
country: 'FR',
}),
detailParams(COMPANY_ID, CUSTOMER_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('CUSTOMER_COUNTRY_MISMATCH')
expect(body.error.details.issue).toBe('VAT_PREFIX_COUNTRY_MISMATCH')
expect(client.captured.update).toHaveLength(0)
})
})
@@ -140,7 +140,7 @@ async function createOneCustomer(
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'Sweden',
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
vat_number_validated: false,
@@ -188,7 +188,7 @@ async function createOneCustomer(
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'Sweden',
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
vat_number_validated: vatValidated,
@@ -351,7 +351,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'Sweden',
country: body.country ?? 'SE',
org_number: body.org_number ?? null,
vat_number: body.vat_number ?? null,
vat_number_validated: false,
@@ -402,7 +402,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'Sweden',
country: body.country ?? 'SE',
org_number: body.org_number ?? null,
vat_number: body.vat_number ?? null,
vat_number_validated: vatValidated,
@@ -191,7 +191,7 @@ async function createOneInvoice(
// immune to refactoring drift.
const { data: customer } = await supabase
.from('customers')
.select('id, customer_type, vat_number_validated')
.select('id, customer_type, vat_number_validated, country')
.eq('company_id', companyId)
.eq('id', input.customer_id)
.maybeSingle()
@@ -207,6 +207,7 @@ async function createOneInvoice(
const vatRules = getVatRules(
customer.customer_type as Parameters<typeof getVatRules>[0],
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
@@ -217,6 +218,7 @@ async function createOneInvoice(
const permittedRates = getPermittedVatRates(
customer.customer_type as Parameters<typeof getPermittedVatRates>[0],
customer.vat_number_validated,
customer.country,
)
const allowedRates = new Set(permittedRates.map((r) => r.rate))
@@ -8,6 +8,7 @@ import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/bu
import { roundOre } from '@/lib/money'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { EU_COUNTRIES } from '@/lib/vat/eu-countries'
import { normalizeCountryCode } from '@/lib/vat/country-codes'
import type { Currency, Customer, CustomerType, Invoice, WebshopOrder } from '@/types'
const EU_COUNTRY_CODES = new Set(EU_COUNTRIES.map((c) => c.code))
@@ -158,6 +159,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
user_id: user.id,
name,
customer_type: customerTypeFromOrder(order),
// The billing country the type above was classified from; without
// it the column default SE would contradict an EU or non-EU type
// and the customer card could not be saved without a country pick.
country: normalizeCountryCode(order.customer_country)
?? (order.customer_country ? order.customer_country.toUpperCase() : 'SE'),
contact_person: order.customer_company ? order.customer_name : null,
email: order.customer_email,
})
+65 -8
View File
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -26,6 +26,12 @@ import {
isMaskedPersonalNumber,
} from '@/lib/customers/mask-personal-number'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import {
COUNTRY_CONSISTENCY_MESSAGES,
checkCountryConsistency,
getCountryOptions,
normalizeCountryCode,
} from '@/lib/vat/country-codes'
import type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
@@ -42,6 +48,8 @@ export default function CustomerForm({
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('form_customer')
const locale = useLocale() === 'en' ? 'en' : 'sv'
const countryOptions = useMemo(() => getCountryOptions(locale), [locale])
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
valid: boolean
@@ -61,7 +69,10 @@ export default function CustomerForm({
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
// ISO 3166-1 alpha-2. A row from before 2026-09 can still carry a name
// the backfill could not map; it is shown as-is in the picker and has to
// be replaced before the form saves.
country: z.string().refine((v) => normalizeCountryCode(v) !== null, t('country_invalid')),
org_number: z.string().optional(),
vat_number: z.string().optional(),
// Accepts a plaintext personnummer or either mask the API returns. The
@@ -78,6 +89,23 @@ export default function CustomerForm({
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}).superRefine((customer, ctx) => {
// Country vs customer type vs VAT prefix (#2025): an EU customer with
// land Sverige got reverse charge and nothing objected until the
// periodisk sammanställning, after the invoice was sent. The API refuses
// the same combinations with a 400; saying it here keeps the fix one
// click away instead of one failed save away.
const countryIssue = checkCountryConsistency({
partyType: customer.customer_type,
country: customer.country,
vatNumber: customer.vat_number,
})
if (countryIssue) {
ctx.addIssue({
code: 'custom',
path: ['country'],
message: COUNTRY_CONSISTENCY_MESSAGES[countryIssue][locale],
})
}
// A personnummer entered as a business org number would be shown
// unmasked in every list (only individual customers are masked).
if (
@@ -113,7 +141,7 @@ export default function CustomerForm({
message: t('invoice_email_too_many', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }),
})
}
}), [t])
}), [t, locale])
type FormData = z.infer<typeof schema>
@@ -137,7 +165,7 @@ export default function CustomerForm({
address_line1: initialData?.address_line1 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: initialData?.country || 'Sweden',
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
@@ -149,6 +177,12 @@ export default function CustomerForm({
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
const countryValue = watch('country')
// A stored value the picker does not list (an unmapped legacy name, or a
// code outside the curated list) still has to be visible, or the field
// would look empty while holding something.
const countryValueUnlisted =
countryValue && !countryOptions.some((option) => option.code === countryValue)
// The stored value could not be decrypted. The field is editable (typing a
// fresh personnummer replaces it); say so, because the placeholder on its own
// reads like a rendering fault.
@@ -392,11 +426,34 @@ export default function CustomerForm({
</div>
<div className="space-y-2">
<Label htmlFor="country">{t('country_label')}</Label>
<Input
id="country"
placeholder={t('country_placeholder')}
{...register('country')}
<Controller
name="country"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger id="country">
<SelectValue placeholder={t('country_placeholder')} />
</SelectTrigger>
<SelectContent>
{countryValueUnlisted && (
<SelectItem value={countryValue}>
{normalizeCountryCode(countryValue)
? countryValue
: t('country_unknown_option', { value: countryValue })}
</SelectItem>
)}
{countryOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{locale === 'en' ? option.nameEn : option.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
</div>
</div>
+1 -1
View File
@@ -1155,7 +1155,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}, 0)
const vatRules = selectedCustomer
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated, selectedCustomer.country)
: null
// Rendered options and the default are deliberately two different sets:
+4 -2
View File
@@ -33,6 +33,8 @@ import type { CustomerType } from '@/types'
export interface VatRateCustomer {
customer_type: CustomerType
vat_number_validated?: boolean | null
/** ISO 3166-1 alpha-2; gates reverse charge together with the two above. */
country?: string | null
}
/** One invoice line as the editor's form holds it. */
@@ -73,8 +75,8 @@ export function resolveLineVatRates(
}
}
const validated = customer.vat_number_validated ?? false
const defaultRates = getAvailableVatRates(customer.customer_type, validated)
const options = getPermittedVatRates(customer.customer_type, validated)
const defaultRates = getAvailableVatRates(customer.customer_type, validated, customer.country)
const options = getPermittedVatRates(customer.customer_type, validated, customer.country)
return {
options,
defaultRates,
+44 -4
View File
@@ -5,7 +5,7 @@ import { useAccounts } from '@/lib/reference-data/hooks'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Loader2, Lock, X } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getCountryOptions, normalizeCountryCode } from '@/lib/vat/country-codes'
import type { CreateSupplierInput } from '@/types'
interface SupplierFormProps {
@@ -29,6 +30,8 @@ export default function SupplierForm({
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_supplier')
const locale = useLocale() === 'en' ? 'en' : 'sv'
const countryOptions = useMemo(() => getCountryOptions(locale), [locale])
// Chart of accounts from the session cache (lib/reference-data): the
// konto combobox is populated on the first paint; without the chart it
// still accepts a typed 4-digit number.
@@ -55,7 +58,9 @@ export default function SupplierForm({
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
// ISO 3166-1 alpha-2; an unmapped legacy name is shown as-is and has to
// be replaced before the form saves.
country: z.string().refine((v) => normalizeCountryCode(v) !== null, t('country_invalid')),
org_number: z.string().optional(),
vat_number: z.string().optional(),
bankgiro: z.string().optional(),
@@ -76,6 +81,7 @@ export default function SupplierForm({
register,
handleSubmit,
control,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -87,7 +93,7 @@ export default function SupplierForm({
address_line1: initialData?.address_line1 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: initialData?.country || 'SE',
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
bankgiro: initialData?.bankgiro || '',
@@ -103,6 +109,13 @@ export default function SupplierForm({
},
})
const countryValue = watch('country')
// A stored value the picker does not list (an unmapped legacy name, or a
// code outside the curated list) still has to be visible, or the field
// would look empty while holding something.
const countryValueUnlisted =
countryValue && !countryOptions.some((option) => option.code === countryValue)
// Empty strings go through as-is: the API schemas normalize them (dropped on
// create, null on update so a cleared field actually clears the column).
const onFormSubmit = (data: FormData) => {
@@ -214,7 +227,34 @@ export default function SupplierForm({
</div>
<div className="space-y-2">
<Label htmlFor="country">{t('country_label')}</Label>
<Input id="country" placeholder="SE" {...register('country')} />
<Controller
name="country"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger id="country">
<SelectValue placeholder={t('country_placeholder')} />
</SelectTrigger>
<SelectContent>
{countryValueUnlisted && (
<SelectItem value={countryValue}>
{normalizeCountryCode(countryValue)
? countryValue
: t('country_unknown_option', { value: countryValue })}
</SelectItem>
)}
{countryOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{locale === 'en' ? option.nameEn : option.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
</div>
</div>
@@ -9,6 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
import { normalizeCountryCode } from '@/lib/vat/country-codes'
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
import type {
@@ -46,7 +47,10 @@ function formatAddress(addr?: PostalAddress): {
address_line2: addr.additionalStreetName || null,
postal_code: addr.postalZone || null,
city: addr.cityName || null,
country: addr.countryCode || null,
// customers.country is ISO 3166-1 alpha-2; some providers hand over a
// name (Fortnox's Country is "Sverige"), which is mapped when known and
// kept as-is otherwise so the periodisk report can point at it.
country: normalizeCountryCode(addr.countryCode) ?? addr.countryCode ?? null,
}
}
@@ -0,0 +1,215 @@
/**
* customers.country through the MCP tools: ISO 3166-1 alpha-2, derived from
* the VAT prefix when an EU business omits it, and checked against the
* customer type at staging (#2025, #2028).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
import { tools } from '../server'
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
const createTool = () => tools.find((candidate) => candidate.name === 'gnubok_create_customer')!
const updateTool = () => tools.find((candidate) => candidate.name === 'gnubok_update_customer')!
function currentCustomer(overrides: Record<string, unknown> = {}) {
return {
id: CUSTOMER_ID,
name: 'Test Customer AB',
customer_type: 'swedish_business',
customer_number: '1001',
email: 'billing@example.test',
phone: '',
address_line1: 'Testgatan 1',
address_line2: null,
postal_code: '12345',
city: 'Teststad',
country: 'SE',
org_number: '556000-0000',
vat_number: null,
vat_number_validated: false,
language: 'sv',
default_payment_terms: 30,
notes: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('gnubok_create_customer: country', () => {
it('documents the code shape and the type rule in the input schema', () => {
const properties = createTool().inputSchema.properties as Record<string, Record<string, unknown>>
expect(String(properties.country.description)).toMatch(/ISO 3166-1 alpha-2/)
expect(String(properties.country.description)).toMatch(/default SE/)
})
it('previews a country name as its code', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // company_settings read (payment-terms default)
const result = (await createTool().execute(
{
name: 'Muster Handels GmbH',
customer_type: 'eu_business',
country: 'Tyskland',
vat_number: 'DE811234567',
dry_run: true,
},
'company-1',
'user-1',
supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.country).toBe('DE')
})
it('derives the country from the VAT prefix when an EU business omits it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = (await createTool().execute(
{ name: 'Muster Handels GmbH', customer_type: 'eu_business', vat_number: 'DE811234567', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.country).toBe('DE')
})
it('defaults a Swedish business to SE, never to the name Sweden', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const result = (await createTool().execute(
{ name: 'Kund AB', customer_type: 'swedish_business', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { preview: Record<string, unknown> }
expect(result.preview.country).toBe('SE')
})
it('refuses an EU business with land Sverige before staging (#2025)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createTool().execute(
{ name: 'Muster Handels GmbH', customer_type: 'eu_business', country: 'SE', vat_number: 'DE811234567' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/country/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('refuses an EU business with neither a country nor a usable VAT prefix', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createTool().execute(
{ name: 'Muster Handels GmbH', customer_type: 'eu_business', vat_number: '811234567' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/country is required/i)
})
it('refuses a country it cannot read as a code', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createTool().execute(
{ name: 'Kund AB', customer_type: 'swedish_business', country: 'Atlantis' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not an ISO 3166-1 alpha-2 code/i)
})
})
describe('gnubok_update_customer: country', () => {
it('previews a country name as its code', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer() })
const result = (await updateTool().execute(
{
customer_id: CUSTOMER_ID,
customer_type: 'eu_business',
country: 'Tyskland',
vat_number: 'DE811234567',
dry_run: true,
},
'company-1',
'user-1',
supabase as never,
)) as { preview: { proposed?: Record<string, unknown> } }
expect(result.preview.proposed).toMatchObject({ customer_type: 'eu_business', country: 'DE' })
})
it('refuses a type change that contradicts the stored country', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer() })
await expect(
updateTool().execute(
{ customer_id: CUSTOMER_ID, customer_type: 'eu_business', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/country/i)
})
it('refuses a country change that contradicts the stored VAT prefix', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567' }) })
await expect(
updateTool().execute(
{ customer_id: CUSTOMER_ID, country: 'FR', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/prefix/i)
})
it('lets a contradictory legacy row change unrelated fields', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer({ customer_type: 'eu_business', country: 'SE', vat_number: 'DE811234567' }) })
const result = (await updateTool().execute(
{ customer_id: CUSTOMER_ID, email: 'new@example.test', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { preview: { proposed?: Record<string, unknown> } }
expect(result.preview.proposed).toMatchObject({ email: 'new@example.test', country: 'SE' })
})
it('refuses a country it cannot read as a code before touching the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{ customer_id: CUSTOMER_ID, country: 'Atlantis', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/country/i)
expect(supabase.from).not.toHaveBeenCalled()
})
})
+64 -11
View File
@@ -139,6 +139,12 @@ import {
} from './tool-namespace'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import {
COUNTRY_CONSISTENCY_MESSAGES,
checkCountryConsistency,
defaultCountryForParty,
normalizeCountryCode,
} from '@/lib/vat/country-codes'
import {
ACCOUNT_VAT_TREATMENTS,
defaultRateForVatTreatment,
@@ -5949,7 +5955,7 @@ export const tools: McpTool[] = [
address: { type: 'string', description: 'Street address' },
postal_code: { type: 'string' },
city: { type: 'string' },
country: { type: 'string', description: 'Country (default Sweden)' },
country: { type: 'string', description: 'ISO 3166-1 alpha-2 (default SE; a name is normalised). Must agree with customer_type and the VAT prefix.' },
dry_run: {
type: 'boolean',
description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.',
@@ -6023,6 +6029,35 @@ export const tools: McpTool[] = [
throw new Error('personal_number must be a Swedish personnummer: YYYYMMDD-XXXX, YYMMDD-XXXX or the digits alone.')
}
// Country: stored as ISO 3166-1 alpha-2 (a name is accepted and
// normalised), and it must agree with the customer type and the VAT
// prefix. An EU customer saved with country SE used to get reverse
// charge with nothing objecting until the periodisk sammanställning
// (#2025). Checked at staging so the user never approves an operation
// that then fails at commit.
const countryArg = typeof args.country === 'string' ? args.country.trim() : ''
const vatNumberArg = typeof args.vat_number === 'string' ? args.vat_number : null
const country = countryArg
? normalizeCountryCode(countryArg)
: defaultCountryForParty(customerType, vatNumberArg)
if (!country) {
throw new Error(
countryArg
? `country "${countryArg}" is not an ISO 3166-1 alpha-2 code or a known country name. Use a code such as SE, DE or NO.`
: customerType === '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.',
)
}
const countryIssue = checkCountryConsistency({
partyType: customerType,
country,
vatNumber: vatNumberArg,
})
if (countryIssue) {
throw new Error(`country: ${COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en}`)
}
// Resolved at staging, not at commit, so the approval preview shows the
// terms the row will actually get: the caller's value, else the
// company's invoice_default_days, else 30. (Staging `|| 30` here is why
@@ -6045,7 +6080,7 @@ export const tools: McpTool[] = [
address: (args.address as string) || null,
postal_code: (args.postal_code as string) || null,
city: (args.city as string) || null,
country: (args.country as string) || 'Sweden',
country,
// The preview (and the approval UI that renders it) only ever sees
// the masked form.
personal_number_masked: personalNumber ? maskCustomerPersonalNumber(personalNumber) : null,
@@ -6103,7 +6138,7 @@ export const tools: McpTool[] = [
address_line2: { type: 'string' },
postal_code: { type: 'string' },
city: { type: 'string' },
country: { type: 'string' },
country: { type: 'string', description: 'ISO 3166-1 alpha-2 (a name is normalised). Must agree with customer_type and the VAT prefix.' },
org_number: { type: 'string' },
personal_number: {
type: ['string', 'null'],
@@ -6199,6 +6234,24 @@ export const tools: McpTool[] = [
throw new Error('personal_number is only allowed for customer_type "individual".')
}
// Country vs type vs VAT prefix, judged on the row as it will END UP:
// a type change alone can make a stored country wrong, and a country
// change alone can contradict the stored VAT number (#2025). Judged
// only when one of the three is in the update: a legacy row that is
// already contradictory must still be able to change its email.
const { customer_type: newType, country: newCountry, vat_number: newVat } = parsed.data.changes
const countryIssue =
newType !== undefined || newCountry !== undefined || newVat !== undefined
? checkCountryConsistency({
partyType: effectiveCustomerType,
country: newCountry ?? current.country,
vatNumber: newVat ?? current.vat_number,
})
: null
if (countryIssue) {
throw new Error(`country: ${COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en}`)
}
const currentPreview = {
customer_id: current.id,
name: current.name,
@@ -6845,13 +6898,13 @@ export const tools: McpTool[] = [
}
// VAT rules from customer type (same logic as web UI)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
// The DEFAULT set governs article-rate adoption (web parity: the picker
// only adopts a rate the customer could have picked themselves); a
// customer locked to a single rate (foreign business 0%) adopts nothing.
// Gating below stays on the PERMITTED set: adoption and validation are
// deliberately different sets.
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country)
// Article prefill (web line picker parity): the line's own values win,
// the referenced article fills whatever the agent left out.
@@ -6922,7 +6975,7 @@ export const tools: McpTool[] = [
// The default is still 0% (vatRules.rate is the fallback below), so a
// Swedish rate only reaches the staged operation when the agent set it on
// that line 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))
// Calculate per-item VAT (line totals net of any per-line discount)
@@ -7240,7 +7293,7 @@ export const tools: McpTool[] = [
// Article prefill with the same rules as gnubok_create_invoice: the
// line's own values win, the article fills the rest, and its VAT rate
// is adopted only inside the customer's default rate set.
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country)
const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a)))
const articlesById = new Map<string, InvoiceLineArticle>()
if (articleIds.length > 0) {
@@ -18076,7 +18129,7 @@ export const tools: McpTool[] = [
// for individuals); never decrypted, staged, or returned here.
const { data: customer, error: custError } = await supabase
.from('customers')
.select('customer_type, vat_number_validated, personal_number')
.select('customer_type, vat_number_validated, country, personal_number')
.eq('id', invoice.customer_id)
.eq('company_id', companyId)
.single()
@@ -18084,9 +18137,9 @@ export const tools: McpTool[] = [
throw new Error('Customer not found: they may have been deleted. The draft cannot be edited without its customer.')
}
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
defaultVatRate = vatRules.rate
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated)
const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country)
const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a)))
const articlesById = new Map<string, InvoiceLineArticle>()
@@ -18114,7 +18167,7 @@ export const tools: McpTool[] = [
// carry Swedish VAT even to a foreign business); the default stays
// vatRules.rate, so a Swedish rate only lands here when set on the
// line or adopted from an article within the default set.
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))
for (const item of items) {
// Text rows carry no amounts and never book: exclude them from the
+84 -6
View File
@@ -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(),
+5
View File
@@ -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)
})
})
+23 -9
View File
@@ -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')
}
+10 -4
View File
@@ -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')
})
})
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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).
+5 -1
View File
@@ -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))
+39 -8
View File
@@ -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,
})
+51 -4
View File
@@ -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')
})
})
+5 -1
View File
@@ -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)
+3 -3
View File
@@ -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)
})
})
+246
View File
@@ -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)
})
})
+370
View File
@@ -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'
}
+6 -1
View File
@@ -1256,7 +1256,9 @@
"city_label": "City",
"city_placeholder": "Stockholm",
"country_label": "Country",
"country_placeholder": "Sweden",
"country_placeholder": "Select country",
"country_invalid": "Pick a country from the list",
"country_unknown_option": "{value} (unknown country)",
"business_section": "Business details",
"individual_section": "Personal details",
"personal_number_label": "Personal number",
@@ -1304,6 +1306,9 @@
"postal_label": "Postal code",
"city_label": "City",
"country_label": "Country",
"country_placeholder": "Select country",
"country_invalid": "Pick a country from the list",
"country_unknown_option": "{value} (unknown country)",
"business_section": "Business details",
"org_number_label": "Org. number",
"org_number_placeholder": "XXXXXX-XXXX",
+6 -1
View File
@@ -1256,7 +1256,9 @@
"city_label": "Ort",
"city_placeholder": "Stockholm",
"country_label": "Land",
"country_placeholder": "Sweden",
"country_placeholder": "Välj land",
"country_invalid": "Välj ett land i listan",
"country_unknown_option": "{value} (okänt land)",
"business_section": "Företagsuppgifter",
"individual_section": "Personuppgifter",
"personal_number_label": "Personnummer",
@@ -1304,6 +1306,9 @@
"postal_label": "Postnummer",
"city_label": "Ort",
"country_label": "Land",
"country_placeholder": "Välj land",
"country_invalid": "Välj ett land i listan",
"country_unknown_option": "{value} (okänt land)",
"business_section": "Företagsuppgifter",
"org_number_label": "Organisationsnummer",
"org_number_placeholder": "XXXXXX-XXXX",
+1 -1
View File
@@ -327,7 +327,7 @@ Example response `200`:
"org_number": "556677-8899",
"vat_number": "SE556677889901",
"vat_number_validated": true,
"country": "Sweden",
"country": "SE",
"default_payment_terms": 30,
"archived_at": null,
"created_at": "2025-04-12T08:30:00Z",
@@ -0,0 +1,233 @@
-- customers.country and suppliers.country are ISO 3166-1 alpha-2 (#2025, #2028).
--
-- The columns have always defaulted to 'SE' and every reader (periodisk
-- sammanställning / SKV 5740, Peppol BIS Billing, the provider importers)
-- treats the value as a code, but the customer form and the v1 API wrote
-- English names ("Sweden", "Germany"). This migration:
--
-- 1. adds public.normalize_country_code(text): the SQL twin of
-- normalizeCountryCode() in lib/vat/country-codes.ts (same table);
-- 2. keeps the pre-backfill text in a new country_raw column on both
-- tables for every row it touches, so the backfill is one UPDATE to undo;
-- 3. maps every row whose country is not already an uppercase code through
-- the function. Names the table does not know are left exactly as they
-- were (and listed in country_raw); the periodisk report already warns
-- on those and the customer form asks for a pick before it saves.
--
-- 4. for eu_business rows whose country is missing or only the old writer
-- default (SE) while the VAT number names another EU member, takes the
-- country from the VAT prefix. The pre-2026-09 VAT rules granted reverse
-- charge on type + VIES validation alone, so these rows invoiced at 0%;
-- without this step they would flip to 25% Swedish VAT on the next
-- invoice. country_raw = '' marks a row whose country was null.
--
-- Rollback (restores the original text on every touched row):
-- update public.customers set country = nullif(country_raw, '') where country_raw is not null;
-- update public.suppliers set country = nullif(country_raw, '') where country_raw is not null;
--
-- Rows still unmapped after the backfill:
-- select id, company_id, name, country from public.customers
-- where country is not null and country !~ '^[A-Z]{2}$';
-- (same for public.suppliers)
create or replace function public.normalize_country_code(input text)
returns text
language plpgsql
immutable
as $$
declare
folded text;
upper_input text;
begin
folded := lower(regexp_replace(btrim(coalesce(input, '')), '\s+', ' ', 'g'));
folded := regexp_replace(folded, '\.+$', '');
if folded = '' then
return null;
end if;
upper_input := upper(btrim(input));
if upper_input ~ '^[A-Z]{2}$' then
if upper_input = 'EL' then return 'GR'; end if;
if upper_input = 'UK' then return 'GB'; end if;
return upper_input;
end if;
return (
select m.code
from (values
('australia', 'AU'),
('australien', 'AU'),
('austria', 'AT'),
('belgien', 'BE'),
('belgium', 'BE'),
('brasilien', 'BR'),
('brazil', 'BR'),
('britain', 'GB'),
('bulgaria', 'BG'),
('bulgarien', 'BG'),
('canada', 'CA'),
('china', 'CN'),
('colombia', 'CO'),
('croatia', 'HR'),
('curaçao', 'CW'),
('cypern', 'CY'),
('cyprus', 'CY'),
('czech republic', 'CZ'),
('czechia', 'CZ'),
('danmark', 'DK'),
('denmark', 'DK'),
('deutschland', 'DE'),
('england', 'GB'),
('estland', 'EE'),
('estonia', 'EE'),
('finland', 'FI'),
('france', 'FR'),
('frankrike', 'FR'),
('förenade arabemiraten', 'AE'),
('germany', 'DE'),
('great britain', 'GB'),
('greece', 'GR'),
('grekland', 'GR'),
('holland', 'NL'),
('hong kong', 'HK'),
('hongkong', 'HK'),
('hungary', 'HU'),
('iceland', 'IS'),
('india', 'IN'),
('indien', 'IN'),
('ireland', 'IE'),
('irland', 'IE'),
('island', 'IS'),
('israel', 'IL'),
('italien', 'IT'),
('italy', 'IT'),
('japan', 'JP'),
('kanada', 'CA'),
('kina', 'CN'),
('kroatien', 'HR'),
('latvia', 'LV'),
('lettland', 'LV'),
('liechtenstein', 'LI'),
('litauen', 'LT'),
('lithuania', 'LT'),
('luxembourg', 'LU'),
('luxemburg', 'LU'),
('malta', 'MT'),
('mexico', 'MX'),
('mexiko', 'MX'),
('nederlanderna', 'NL'),
('nederländerna', 'NL'),
('netherlands', 'NL'),
('new zealand', 'NZ'),
('norge', 'NO'),
('norway', 'NO'),
('nya zeeland', 'NZ'),
('osterrike', 'AT'),
('poland', 'PL'),
('polen', 'PL'),
('portugal', 'PT'),
('republic of ireland', 'IE'),
('republic of korea', 'KR'),
('romania', 'RO'),
('rumänien', 'RO'),
('saint kitts & nevis', 'KN'),
('saint kitts and nevis', 'KN'),
('saint kitts och nevis', 'KN'),
('schweiz', 'CH'),
('serbia', 'RS'),
('serbien', 'RS'),
('singapore', 'SG'),
('slovakia', 'SK'),
('slovakien', 'SK'),
('slovenia', 'SI'),
('slovenien', 'SI'),
('south africa', 'ZA'),
('south korea', 'KR'),
('spain', 'ES'),
('spanien', 'ES'),
('st kitts & nevis', 'KN'),
('st kitts and nevis', 'KN'),
('st. kitts and nevis', 'KN'),
('storbritannien', 'GB'),
('suisse', 'CH'),
('sverige', 'SE'),
('sweden', 'SE'),
('switzerland', 'CH'),
('sydafrika', 'ZA'),
('sydkorea', 'KR'),
('thailand', 'TH'),
('the netherlands', 'NL'),
('tjeckien', 'CZ'),
('turkey', 'TR'),
('turkiet', 'TR'),
('turkiye', 'TR'),
('tyskland', 'DE'),
('türkiye', 'TR'),
('u.s.a', 'US'),
('uae', 'AE'),
('uk', 'GB'),
('ukraina', 'UA'),
('ukraine', 'UA'),
('ungern', 'HU'),
('united arab emirates', 'AE'),
('united kingdom', 'GB'),
('united kingdom of great britain and northern ireland', 'GB'),
('united states', 'US'),
('united states of america', 'US'),
('usa', 'US'),
('österrike', 'AT')
) as m(name, code)
where m.name = folded
limit 1
);
end;
$$;
comment on function public.normalize_country_code(text) is
'ISO 3166-1 alpha-2 from a code in any case, EL/UK, or a Swedish/English country name; null when unknown. Mirrors lib/vat/country-codes.ts.';
alter table public.customers add column if not exists country_raw text;
alter table public.suppliers add column if not exists country_raw text;
comment on column public.customers.country_raw is
'The free-text country the row held before the 2026-09 ISO backfill, kept for rollback; null for rows the backfill did not touch.';
comment on column public.suppliers.country_raw is
'The free-text country the row held before the 2026-09 ISO backfill, kept for rollback; null for rows the backfill did not touch.';
-- Backfill: every row that is not already an uppercase alpha-2 code. An
-- empty string is "no country" and becomes null (the periodisk report
-- already treats both the same); a null stays null, nothing is guessed.
update public.customers
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
update public.suppliers
set country_raw = country,
country = case when btrim(country) = '' then null else coalesce(public.normalize_country_code(country), country) end
where country is not null
and country !~ '^[A-Z]{2}$';
-- Step 4: EU-business rows with a missing or defaulted (SE) country and a
-- VAT number registered in another EU member. Only a prefix that names a
-- member is used; a number without a prefix derives nothing and the row
-- keeps its country.
update public.customers c
set country_raw = coalesce(c.country_raw, c.country, ''),
country = d.code
from (
select id,
public.normalize_country_code(
substring(upper(regexp_replace(coalesce(vat_number, ''), '[\s.\-]', '', 'g')) from 1 for 2)
) as code
from public.customers
where customer_type = 'eu_business'
and (country is null or country = 'SE')
) d
where d.id = c.id
and d.code is not null
and d.code <> 'SE'
and d.code in ('AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES');
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,81 @@
/**
* pg-real test for migration 20260903170000: customers.country and
* suppliers.country are ISO 3166-1 alpha-2 (#2025, #2028).
*
* The backfill ran once when the migration was applied, so what can be
* pinned here is the function it used (the SQL twin of normalizeCountryCode
* in lib/vat/country-codes.ts), the rollback column it left behind, and that
* the column default is still the code, not a name.
*/
import { describe, it, expect } from 'vitest'
import { getPool } from './setup'
async function normalize(input: string | null): Promise<string | null> {
const { rows } = await getPool().query<{ code: string | null }>(
'select public.normalize_country_code($1) as code',
[input],
)
return rows[0].code
}
describe('normalize_country_code()', () => {
it('passes codes through uppercased and maps EL/UK', async () => {
expect(await normalize('SE')).toBe('SE')
expect(await normalize('de')).toBe('DE')
expect(await normalize(' no ')).toBe('NO')
expect(await normalize('EL')).toBe('GR')
expect(await normalize('UK')).toBe('GB')
})
it('maps the Swedish and English names the form and the v1 API wrote', async () => {
expect(await normalize('Sweden')).toBe('SE')
expect(await normalize('Sverige')).toBe('SE')
expect(await normalize('GERMANY')).toBe('DE')
expect(await normalize('Tyskland')).toBe('DE')
expect(await normalize('Deutschland')).toBe('DE')
expect(await normalize(' Nederländerna ')).toBe('NL')
expect(await normalize('United States of America')).toBe('US')
expect(await normalize('U.S.A.')).toBe('US')
expect(await normalize('Grekland')).toBe('GR')
})
it('returns null for empty input and names it does not know', async () => {
expect(await normalize(null)).toBeNull()
expect(await normalize('')).toBeNull()
expect(await normalize(' ')).toBeNull()
expect(await normalize('Atlantis')).toBeNull()
expect(await normalize('S')).toBeNull()
expect(await normalize('SWE')).toBeNull()
// The two leading characters of a prefix-less VAT number (backfill step 4).
expect(await normalize('81')).toBeNull()
})
})
describe('country columns after migration 20260903170000', () => {
it('keeps the pre-backfill text in country_raw on both tables', async () => {
const { rows } = await getPool().query<{ table_name: string; column_name: string }>(
`select table_name, column_name
from information_schema.columns
where table_schema = 'public'
and table_name in ('customers', 'suppliers')
and column_name = 'country_raw'
order by table_name`,
)
expect(rows.map((r) => r.table_name)).toEqual(['customers', 'suppliers'])
})
it('defaults country to the code SE on both tables', async () => {
const { rows } = await getPool().query<{ table_name: string; column_default: string | null }>(
`select table_name, column_default
from information_schema.columns
where table_schema = 'public'
and table_name in ('customers', 'suppliers')
and column_name = 'country'
order by table_name`,
)
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.column_default).toContain("'SE'")
}
})
})
+2
View File
@@ -834,6 +834,7 @@ export interface Customer {
address_line2: string | null
postal_code: string | null
city: string | null
/** ISO 3166-1 alpha-2 ('SE', 'DE'). Rows from before 2026-09 that the backfill could not map may still hold a name. */
country: string
// Tax info
@@ -872,6 +873,7 @@ export interface Supplier {
address_line2: string | null
postal_code: string | null
city: string | null
/** ISO 3166-1 alpha-2 ('SE', 'DE'). Rows from before 2026-09 that the backfill could not map may still hold a name. */
country: string
org_number: string | null