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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user