* 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>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|