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
co-authored by Claude Fable 5.1
parent 80b87c55fc
commit 3918ff6620
48 changed files with 2288 additions and 97 deletions
+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,
})