fix(arcim-migration): treat 12-digit Swedish numbers as domestic + route personnummer to personal_number (#799)

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 <jonas@lindan.se>
This commit is contained in:
Jonas Hagberg
2026-06-29 22:13:55 +02:00
committed by GitHub
parent 47f03f4238
commit 96989f1cde
2 changed files with 104 additions and 6 deletions
@@ -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')
})
})
@@ -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<string, unknown> {
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,