* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and the v1 REST API, but not the MCP path, and the web customer list still showed a personnummer raw when it sat in org_number. Personnummer (MCP + every write path): - gnubok_create_customer gets a personal_number input. Until now it had none, so an agent creating a private person either dropped the number or put it in org_number, which nothing masks. Encrypted at staging (personal_number_encrypted + personal_number_masked; personal_number is now a forbidden staging key in staging-pii-guard), the approval preview shows ********-1234, commitCreateCustomer stores the ciphertext as-is. Idempotency hashes the masked preview (new StageOptions.idempotencyParams) because the random-IV ciphertext would make identical retries look like payload changes. - A personnummer-shaped org_number on customer_type=individual is the personnummer in the wrong field: it is moved into personal_number (encrypted) and org_number cleared, on CreateCustomerSchema (web POST, v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer for in-flight ops. Only a DIFFERENT personnummer next to personal_number is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type guard from #1724 is unchanged and now also fires at MCP staging, so the user never approves an operation that fails at commit. - Read side: the web customer list and gnubok_list_customers mask a legacy individual row's org_number personnummer instead of showing it raw; list_customers exposes personal_number_masked and never the ciphertext. - scripts/repair-customer-personal-number-in-org-number.ts moves the existing rows (dry run: 134 rows across 10 companies on prod); run by hand with --confirm after deploy. - customer-onboarding skill: EF customers follow the #1724 decision (individual + personal_number); ROT/RUT section names the real field. Payment terms (MCP): - gnubok_create_customer staged `payment_terms || 30`, so resolveDefaultPaymentTerms at commit always saw 30 and the company's invoice_default_days never reached MCP customers. Resolved at staging now, so the preview shows the value the row will get. tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first, rationale in payload-size.bench.test.ts). apiskill regenerated; no migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk * fix(scripts): literal update payloads in the personnummer repair script The no-phantom-columns scanner counts a runtime-built update payload as unresolvable and the ceiling (379) had no headroom; two literal payloads keep the guard able to resolve both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
82 lines
4.0 KiB
TypeScript
82 lines
4.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
looksLikeSwedishPersonalNumber,
|
|
normalizeReroutedPersonalNumber,
|
|
orgNumberHoldsPersonalNumber,
|
|
personalNumberDigits,
|
|
} from '@/lib/customers/personal-number-shape'
|
|
|
|
// Every personal-shaped fixture is synthetic, never a real person's number.
|
|
describe('looksLikeSwedishPersonalNumber', () => {
|
|
it('recognizes a 10-digit personnummer with and without separator', () => {
|
|
expect(looksLikeSwedishPersonalNumber('900101-1234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('9001011234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('900101+1234')).toBe(true)
|
|
})
|
|
|
|
it('recognizes a 12-digit personnummer for all personal centuries', () => {
|
|
expect(looksLikeSwedishPersonalNumber('19900101-1234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('199001011234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('200412241234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('189912311234')).toBe(true)
|
|
})
|
|
|
|
it('recognizes a samordningsnummer (day offset by 60)', () => {
|
|
expect(looksLikeSwedishPersonalNumber('19900161-1234')).toBe(true)
|
|
expect(looksLikeSwedishPersonalNumber('900191-1234')).toBe(true)
|
|
})
|
|
|
|
it('rejects legal-entity organisationsnummer (month position >= 20)', () => {
|
|
expect(looksLikeSwedishPersonalNumber('556677-8899')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('5566778899')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('212000-0142')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('165566778899')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('16556677-8899')).toBe(false)
|
|
})
|
|
|
|
it('rejects values that are neither shape', () => {
|
|
expect(looksLikeSwedishPersonalNumber('')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('SE556677889901')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('12345')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('19901301-1234')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('19900145-1234')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('19900199-1234')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('179001011234')).toBe(false)
|
|
expect(looksLikeSwedishPersonalNumber('************')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('orgNumberHoldsPersonalNumber', () => {
|
|
it('is true only for an individual whose org_number has personnummer shape', () => {
|
|
expect(orgNumberHoldsPersonalNumber('individual', '19900101-1234')).toBe(true)
|
|
expect(orgNumberHoldsPersonalNumber('individual', '9001011234')).toBe(true)
|
|
expect(orgNumberHoldsPersonalNumber('individual', '19900101 1234')).toBe(true)
|
|
})
|
|
|
|
it('is false for business types, legal-entity org numbers, and empty values', () => {
|
|
// A business carrying a personnummer is the other guard's job (reject),
|
|
// not a reroute.
|
|
expect(orgNumberHoldsPersonalNumber('swedish_business', '19900101-1234')).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber('individual', '556677-8899')).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber('individual', '')).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber('individual', ' ')).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber('individual', null)).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber('individual', undefined)).toBe(false)
|
|
expect(orgNumberHoldsPersonalNumber(undefined, '19900101-1234')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('normalizeReroutedPersonalNumber / personalNumberDigits', () => {
|
|
it('drops whitespace but keeps the separator', () => {
|
|
expect(normalizeReroutedPersonalNumber('19900101 1234')).toBe('199001011234')
|
|
expect(normalizeReroutedPersonalNumber(' 19900101-1234 ')).toBe('19900101-1234')
|
|
expect(normalizeReroutedPersonalNumber('900101+1234')).toBe('900101+1234')
|
|
})
|
|
|
|
it('compares across written forms by digits only', () => {
|
|
expect(personalNumberDigits('19900101-1234')).toBe('199001011234')
|
|
expect(personalNumberDigits('19900101-1234')).toBe(personalNumberDigits('199001011234'))
|
|
expect(personalNumberDigits('900101-1234')).not.toBe(personalNumberDigits('19900101-1234'))
|
|
})
|
|
})
|