diff --git a/DECISIONS.md b/DECISIONS.md index 77b8cf5d..438e1235 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1527,4 +1527,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index e4cd0fee..8f64be0c 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -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) && (

{[customer.postal_code, customer.city].filter(Boolean).join(' ')}

)} - {customer.country &&

{customer.country}

} + {customer.country &&

{getCountryName(customer.country, errorLocale)}

} ) : ( diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index dce54a08..9417360a 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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 &&

{customer.address_line2}

}

{[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')}`}

) : ( diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 49e2f7fa..1ffc1b1f 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -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 = {} if (body.name !== undefined) updateData.name = body.name if (body.customer_type !== undefined) updateData.customer_type = body.customer_type diff --git a/app/api/customers/__tests__/country.test.ts b/app/api/customers/__tests__/country.test.ts new file mode 100644 index 00000000..490e3298 --- /dev/null +++ b/app/api/customers/__tests__/country.test.ts @@ -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') + }) +}) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 3d05e7a4..6878cafc 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -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), diff --git a/app/api/import/customers/execute/route.ts b/app/api/import/customers/execute/route.ts index 8bf939fb..98238993 100644 --- a/app/api/import/customers/execute/route.ts +++ b/app/api/import/customers/execute/route.ts @@ -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, diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index d94d08d6..e1ad80b7 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -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' diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index e7102dd5..06bfd085 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 6a02b4a1..8c36820a 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -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') { diff --git a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts index 569496cf..a669bed9 100644 --- a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts index 6e243bba..87a12c52 100644 --- a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 4fa3a73c..7b3857f2 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts index 1dffcf61..4429a7c4 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -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[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[0], customer.vat_number_validated, + customer.country, ) const allowedRates = new Set(permittedRates.map((r) => r.rate)) diff --git a/app/api/webshop-orders/[id]/create-invoice/route.ts b/app/api/webshop-orders/[id]/create-invoice/route.ts index 89e22d8b..fbe2927e 100644 --- a/app/api/webshop-orders/[id]/create-invoice/route.ts +++ b/app/api/webshop-orders/[id]/create-invoice/route.ts @@ -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, }) diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 20454b15..f429162c 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -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 @@ -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({
- ( + + )} /> + {errors.country && ( +

{errors.country.message}

+ )}
diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 7349024f..58a481c5 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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: diff --git a/components/invoices/line-vat-rates.ts b/components/invoices/line-vat-rates.ts index cd94a043..2d1cede2 100644 --- a/components/invoices/line-vat-rates.ts +++ b/components/invoices/line-vat-rates.ts @@ -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, diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index d1a09c63..ad1d5e28 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -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({ 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({
- + ( + + )} + /> + {errors.country && ( +

{errors.country.message}

+ )}
diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index e3c0bfe3..c3fafc2f 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -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, } } diff --git a/extensions/general/mcp-server/__tests__/customer-country.test.ts b/extensions/general/mcp-server/__tests__/customer-country.test.ts new file mode 100644 index 00000000..db8ef95d --- /dev/null +++ b/extensions/general/mcp-server/__tests__/customer-country.test.ts @@ -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 = {}) { + 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> + 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 } + + 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 } + + 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 } + + 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 } } + + 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 } } + + 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() + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index b1c97f01..8b5d6bda 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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() 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() @@ -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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 470ea86b..aef68368 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index b22b4c43..8d1bba90 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2759,6 +2759,11 @@ const ARTICLE: Record = { 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: diff --git a/lib/import/customers/__tests__/parser.test.ts b/lib/import/customers/__tests__/parser.test.ts index 25c0e0e9..a33052fc 100644 --- a/lib/import/customers/__tests__/parser.test.ts +++ b/lib/import/customers/__tests__/parser.test.ts @@ -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) + }) }) diff --git a/lib/import/customers/parser.ts b/lib/import/customers/parser.ts index 17981871..48982b57 100644 --- a/lib/import/customers/parser.ts +++ b/lib/import/customers/parser.ts @@ -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') } diff --git a/lib/import/suppliers/parser.ts b/lib/import/suppliers/parser.ts index e51ac631..46555013 100644 --- a/lib/import/suppliers/parser.ts +++ b/lib/import/suppliers/parser.ts @@ -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') } diff --git a/lib/invoices/__tests__/build-invoice-write.test.ts b/lib/invoices/__tests__/build-invoice-write.test.ts index f24bdedf..0f388694 100644 --- a/lib/invoices/__tests__/build-invoice-write.test.ts +++ b/lib/invoices/__tests__/build-invoice-write.test.ts @@ -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: [ diff --git a/lib/invoices/__tests__/vat-rules-country.test.ts b/lib/invoices/__tests__/vat-rules-country.test.ts new file mode 100644 index 00000000..c339812e --- /dev/null +++ b/lib/invoices/__tests__/vat-rules-country.test.ts @@ -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') + }) +}) diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts index 10dda358..f70cfd00 100644 --- a/lib/invoices/build-invoice-write.ts +++ b/lib/invoices/build-invoice-write.ts @@ -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 diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 4ef1f62d..4b7c52ee 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -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 {customer.postal_code} {customer.city} )} {customer.country && customer.country !== 'SE' && ( - {customer.country} + {getCountryName(customer.country, lang)} )} {/* Seller-assigned kundnummer: no per-customer-type guard needed, it identifies the customer in the seller's own register and diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index 33c61fa7..96b08f17 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -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). diff --git a/lib/invoices/self-billed-sale.ts b/lib/invoices/self-billed-sale.ts index d2f5a93a..bfe72628 100644 --- a/lib/invoices/self-billed-sale.ts +++ b/lib/invoices/self-billed-sale.ts @@ -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[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[0], c.vat_number_validated ?? undefined, + c.country, ) const allowedRates = new Set(permittedRates.map((r) => r.rate)) diff --git a/lib/invoices/vat-rules.ts b/lib/invoices/vat-rules.ts index af65a080..75eac84a 100644 --- a/lib/invoices/vat-rules.ts +++ b/lib/invoices/vat-rules.ts @@ -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 { - 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, diff --git a/lib/pending-operations/__tests__/create-invoice-executor.test.ts b/lib/pending-operations/__tests__/create-invoice-executor.test.ts index 7a3afbe0..ebc085bd 100644 --- a/lib/pending-operations/__tests__/create-invoice-executor.test.ts +++ b/lib/pending-operations/__tests__/create-invoice-executor.test.ts @@ -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, }) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 29152772..77abd9cf 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts index 20570136..c7b6ef3a 100644 --- a/lib/reports/__tests__/periodisk-sammanstallning.test.ts +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -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') + }) +}) diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts index 984217ab..9df05138 100644 --- a/lib/reports/periodisk-sammanstallning.ts +++ b/lib/reports/periodisk-sammanstallning.ts @@ -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) diff --git a/lib/sales-orders/lines.ts b/lib/sales-orders/lines.ts index ef9b1211..4de9bf5e 100644 --- a/lib/sales-orders/lines.ts +++ b/lib/sales-orders/lines.ts @@ -40,11 +40,11 @@ export type NormalizeLinesResult = export function normalizeSalesOrderLines( items: SalesOrderItemInput[], - customer: Pick, + customer: Pick & { 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[] = [] diff --git a/lib/vat/__tests__/country-codes-sql-parity.test.ts b/lib/vat/__tests__/country-codes-sql-parity.test.ts new file mode 100644 index 00000000..8de29c2e --- /dev/null +++ b/lib/vat/__tests__/country-codes-sql-parity.test.ts @@ -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 { + const sql = readFileSync(MIGRATION, 'utf-8') + const table = new Map() + 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) + }) +}) diff --git a/lib/vat/__tests__/country-codes.test.ts b/lib/vat/__tests__/country-codes.test.ts new file mode 100644 index 00000000..96480cf2 --- /dev/null +++ b/lib/vat/__tests__/country-codes.test.ts @@ -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) + }) +}) diff --git a/lib/vat/country-codes.ts b/lib/vat/country-codes.ts new file mode 100644 index 00000000..b5463b29 --- /dev/null +++ b/lib/vat/country-codes.ts @@ -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 = { + 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 = (() => { + const map = new Map() + 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 = { 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' +} diff --git a/messages/en.json b/messages/en.json index 171590c7..17b514cc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 684794be..34c300b0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/skills/accounted-api/references/customers.md b/skills/accounted-api/references/customers.md index 43aba750..edef6aee 100644 --- a/skills/accounted-api/references/customers.md +++ b/skills/accounted-api/references/customers.md @@ -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", diff --git a/supabase/migrations/20260903170000_customer_supplier_country_iso.sql b/supabase/migrations/20260903170000_customer_supplier_country_iso.sql new file mode 100644 index 00000000..25550130 --- /dev/null +++ b/supabase/migrations/20260903170000_customer_supplier_country_iso.sql @@ -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'; diff --git a/tests/pg/normalize-country-code.pg.test.ts b/tests/pg/normalize-country-code.pg.test.ts new file mode 100644 index 00000000..1c03e0d3 --- /dev/null +++ b/tests/pg/normalize-country-code.pg.test.ts @@ -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 { + 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'") + } + }) +}) diff --git a/types/index.ts b/types/index.ts index 38b99632..80790972 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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