From 96989f1cde28dca31824439354a9be4431d6cbb1 Mon Sep 17 00:00:00 2001 From: Jonas Hagberg Date: Mon, 29 Jun 2026 22:13:55 +0200 Subject: [PATCH] fix(arcim-migration): treat 12-digit Swedish numbers as domestic + route personnummer to personal_number (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer/supplier type inference flagged any identity number whose digit count was not exactly 10 as a foreign org number, so a 12-digit century-prefixed personnummer (e.g. 19700616-7113) was misclassified as non_eu_business instead of swedish_business — applying export VAT to a domestic party. Recognize both 10- and 12-digit (19xx/20xx) Swedish numbers via a new looksLikeSwedishIdNumber helper. Separately, mapCustomer always wrote the provider's single identity-number field into org_number, even for individuals. The customer form renders personal_number for individuals (org_number for businesses), so a Privatperson's personnummer was stored in a column the UI hides. Route the number to personal_number for individuals. Adds entity-mapper-customer-type unit tests. Signed-off-by: Jonas Hagberg --- .../entity-mapper-customer-type.test.ts | 72 +++++++++++++++++++ .../arcim-migration/lib/entity-mapper.ts | 38 ++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 extensions/general/arcim-migration/lib/__tests__/entity-mapper-customer-type.test.ts diff --git a/extensions/general/arcim-migration/lib/__tests__/entity-mapper-customer-type.test.ts b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-customer-type.test.ts new file mode 100644 index 00000000..9295c801 --- /dev/null +++ b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-customer-type.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { mapCustomer } from '../entity-mapper' +import type { CustomerDto, PartyDto } from '@/lib/providers/dto' + +/** + * Guards customer type inference + identity-number routing in mapCustomer: + * - a 12-digit (century-prefixed) Swedish personnummer must read as domestic, + * not be misfiled as a foreign org number → non_eu_business (the Johan + * Ekengren 19700616-7113 bug); + * - an individual's number must land in personal_number (not org_number), or + * the individual customer form — which renders personal_number — hides it. + */ + +function makeCustomer(over: { + type?: 'company' | 'private' + number?: string | null + vatNumber?: string + name?: string + countryCode?: string +}): CustomerDto { + const party: PartyDto = { + name: over.name ?? 'Test Kund', + identifications: over.number ? [{ schemeId: 'SE:ORGNR', id: over.number }] : [], + postalAddress: over.countryCode ? { countryCode: over.countryCode } : undefined, + } + return { + id: 'cust-1', + customerNumber: '1', + type: over.type, + party, + active: true, + vatNumber: over.vatNumber, + defaultPaymentTermsDays: 30, + } +} + +describe('mapCustomer — type inference & identity-number routing', () => { + it('12-digit personnummer (no VAT, provider type=company) → swedish_business, not non_eu', () => { + const row = mapCustomer(makeCustomer({ type: 'company', number: '19700616-7113' }), 'u', 'c') + expect(row.customer_type).toBe('swedish_business') + expect(row.org_number).toBe('19700616-7113') + expect(row.personal_number).toBeNull() + }) + + it('provider type=private → individual, personnummer routed to personal_number', () => { + const row = mapCustomer(makeCustomer({ type: 'private', number: '930722-3207' }), 'u', 'c') + expect(row.customer_type).toBe('individual') + expect(row.personal_number).toBe('930722-3207') + expect(row.org_number).toBeNull() + }) + + it('10-digit personnummer (provider type=company) still → swedish_business (unchanged)', () => { + const row = mapCustomer(makeCustomer({ type: 'company', number: '930722-3207' }), 'u', 'c') + expect(row.customer_type).toBe('swedish_business') + expect(row.org_number).toBe('930722-3207') + }) + + it('10-digit org number → swedish_business', () => { + const row = mapCustomer(makeCustomer({ type: 'company', number: '556055-1234' }), 'u', 'c') + expect(row.customer_type).toBe('swedish_business') + }) + + it('non-Swedish-format number → non_eu_business', () => { + const row = mapCustomer(makeCustomer({ type: 'company', number: '12345678' }), 'u', 'c') + expect(row.customer_type).toBe('non_eu_business') + }) + + it('EU VAT prefix wins over number heuristics → eu_business', () => { + const row = mapCustomer(makeCustomer({ type: 'company', vatNumber: 'DE123456789' }), 'u', 'c') + expect(row.customer_type).toBe('eu_business') + }) +}) diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index 88e715d7..2604995e 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -67,6 +67,21 @@ function looksLikeSwedishOrgNumber(orgNumber: string | null | undefined): boolea return thirdDigit >= 2 } +/** + * Check if a string looks like a Swedish identity number — an organisation + * number or personnummer in 10-digit form, or a personnummer in the 12-digit + * century-prefixed form (19xx / 20xx). Used to avoid misclassifying a domestic + * party as foreign just because its number isn't exactly 10 digits: a 12-digit + * personnummer like 19700616-7113 is Swedish, not an unknown foreign org number. + */ +function looksLikeSwedishIdNumber(orgNumber: string | null | undefined): boolean { + if (!orgNumber) return false + const digits = orgNumber.replace(/[-+\s]/g, '') + if (!/^\d+$/.test(digits)) return false + if (digits.length === 10) return true + return digits.length === 12 && /^(19|20)/.test(digits) +} + /** * Company name suffixes that indicate a foreign (non-Swedish) entity. * These override the default swedish_business assumption when no other @@ -148,11 +163,13 @@ function inferTypeFromVatOrCountry( // 3. Swedish-format org number is strong evidence of domestic entity if (looksLikeSwedishOrgNumber(orgNumber)) return 'swedish_business' - // 4. Non-Swedish org number format (wrong digit count) → not Swedish + // 4. A number that isn't a Swedish-format identity number → foreign entity. + // Accepts both 10-digit and 12-digit (century-prefixed) Swedish numbers so + // a domestic personnummer like 19700616-7113 isn't treated as foreign. if (orgNumber) { - const digits = orgNumber.replace(/[-\s]/g, '') - if (digits.length > 0 && digits.length !== 10) { - // Not a Swedish org number — use name heuristic or default to non_eu + const digits = orgNumber.replace(/[-+\s]/g, '') + if (digits.length > 0 && !looksLikeSwedishIdNumber(orgNumber)) { + // Not a Swedish number — use name heuristic or default to non_eu const nameRegion = inferRegionFromName(companyName) if (nameRegion === 'eu') return 'eu_business' return 'non_eu_business' @@ -221,15 +238,24 @@ function inferVatRate(taxPercent?: number): number { export function mapCustomer(dto: CustomerDto, userId: string, companyId: string): Record { const addr = formatAddress(dto.party.postalAddress) + const customerType = inferCustomerType(dto) + const number = getOrgNumber(dto.party) + // The provider exposes a single identity-number field, but Accounted stores a + // personnummer in `personal_number` (individuals) and an org number in + // `org_number` (businesses). Route it to the column the type expects — else a + // Privatperson's personnummer lands in org_number and is hidden by the + // individual customer form, which renders personal_number for individuals. + const isIndividual = customerType === 'individual' return { user_id: userId, company_id: companyId, name: dto.party.name, - customer_type: inferCustomerType(dto), + customer_type: customerType, email: dto.party.contact?.email || null, phone: dto.party.contact?.telephone || null, ...addr, - org_number: getOrgNumber(dto.party), + org_number: isIndividual ? null : number, + personal_number: isIndividual ? number : null, vat_number: dto.vatNumber || null, vat_number_validated: false, default_payment_terms: dto.defaultPaymentTermsDays || 30,