fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)

* 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>
This commit is contained in:
Jakob Wennberg
2026-08-21 18:32:17 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a5c8f55127
commit 13b69a2056
26 changed files with 1299 additions and 36 deletions
+73
View File
@@ -729,6 +729,79 @@ describe('CreateCustomerSchema', () => {
})
})
// Where an individual's personnummer lands (#1707 follow-up). Synthetic
// personnummer throughout, never a real one.
describe('CreateCustomerSchema: personnummer placement', () => {
it('moves a personnummer-shaped org_number on an individual into personal_number', () => {
const result = CreateCustomerSchema.safeParse({
name: 'Anna Andersson',
customer_type: 'individual',
org_number: '19900101-1234',
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.personal_number).toBe('19900101-1234')
expect(result.data.org_number).toBeUndefined()
}
})
it('strips whitespace from the moved value so it matches the personnummer input forms', () => {
const result = CreateCustomerSchema.safeParse({
name: 'Anna Andersson',
customer_type: 'individual',
org_number: '19900101 1234',
})
expect(result.success).toBe(true)
if (result.success) expect(result.data.personal_number).toBe('199001011234')
})
it('drops org_number when it duplicates personal_number', () => {
const result = CreateCustomerSchema.safeParse({
name: 'Anna Andersson',
customer_type: 'individual',
org_number: '199001011234',
personal_number: '19900101-1234',
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.personal_number).toBe('19900101-1234')
expect(result.data.org_number).toBeUndefined()
}
})
it('rejects an org_number that is a different personnummer than personal_number', () => {
const result = CreateCustomerSchema.safeParse({
name: 'Anna Andersson',
customer_type: 'individual',
org_number: '19850505-5555',
personal_number: '19900101-1234',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'org_number')).toBe(true)
}
})
it('still rejects a personnummer-shaped org_number on a business customer', () => {
const result = CreateCustomerSchema.safeParse(validCustomer({ org_number: '19900101-1234' }))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.join('.') === 'org_number')).toBe(true)
}
})
it('leaves a legal-entity organisationsnummer alone on every customer_type', () => {
for (const customer_type of ['individual', 'swedish_business'] as const) {
const result = CreateCustomerSchema.safeParse({ name: 'X', customer_type, org_number: '556677-8899' })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.org_number).toBe('556677-8899')
expect(result.data.personal_number).toBeUndefined()
}
}
})
})
// ============================================================
// Supplier schemas
// ============================================================
+38 -1
View File
@@ -22,7 +22,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 { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import {
looksLikeSwedishPersonalNumber,
normalizeReroutedPersonalNumber,
orgNumberHoldsPersonalNumber,
personalNumberDigits,
} from '@/lib/customers/personal-number-shape'
import type { AuditAction, Currency } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
@@ -951,6 +956,23 @@ export const CreateCustomerSchema = z.object({
+ 'instead, so it is stored encrypted and masked in list responses.',
})
}
// An individual's personnummer submitted as org_number is moved into
// personal_number by the transform below. Next to a DIFFERENT
// personal_number in the same body the two conflict, and guessing which
// one the caller meant is worse than a 400.
if (
customer.personal_number
&& orgNumberHoldsPersonalNumber(customer.customer_type, customer.org_number)
&& personalNumberDigits(customer.org_number!) !== personalNumberDigits(customer.personal_number)
) {
ctx.addIssue({
code: 'custom',
path: ['org_number'],
message:
'org_number looks like a Swedish personal identity number (personnummer) and differs from '
+ 'personal_number. An individual customer keeps its personnummer in personal_number; leave org_number empty.',
})
}
if (
(customer.invoice_email_cc_addresses?.length ?? 0)
+ (customer.invoice_email_bcc_addresses?.length ?? 0)
@@ -962,6 +984,21 @@ export const CreateCustomerSchema = z.object({
message: `At most ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} customer invoice copy recipients are allowed in total`,
})
}
}).transform((customer) => {
// 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
// "org_number accepted as input" for individuals). Nothing masks
// org_number, so it is moved into personal_number, where the routes
// encrypt it and every read returns ********-1234, and org_number is left
// empty. With an equal personal_number already present only the duplicate
// is dropped; an unequal one was refused above.
if (!orgNumberHoldsPersonalNumber(customer.customer_type, customer.org_number)) return customer
return {
...customer,
org_number: undefined,
personal_number: customer.personal_number || normalizeReroutedPersonalNumber(customer.org_number!),
}
})
export const UpdateCustomerSchema = z.object({
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
PERSONAL_NUMBER_PLAINTEXT_RE,
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
customerListIdentifier,
} from '@/lib/customers/mask-personal-number'
// Synthetic personnummer throughout, never a real one.
describe('customerListIdentifier', () => {
it('shows org_number for business rows', () => {
expect(customerListIdentifier({ customer_type: 'swedish_business', org_number: '556677-8899' })).toBe('556677-8899')
expect(customerListIdentifier({ customer_type: 'swedish_business', org_number: null })).toBe('')
})
it('shows the masked personal_number for individual rows, passing an API mask through', () => {
expect(customerListIdentifier({ customer_type: 'individual', personal_number: '********-1234' })).toBe('********-1234')
expect(customerListIdentifier({ customer_type: 'individual', personal_number: '19900101-1234' })).toBe('********-1234')
expect(
customerListIdentifier({ customer_type: 'individual', personal_number: UNDECRYPTABLE_PERSONAL_NUMBER_MASK }),
).toBe(UNDECRYPTABLE_PERSONAL_NUMBER_MASK)
})
it('masks a legacy individual row that still carries its personnummer in org_number', () => {
expect(customerListIdentifier({ customer_type: 'individual', org_number: '19900101-1234', personal_number: null })).toBe(
'********-1234',
)
})
it('leaves a non-personnummer org_number on an individual visible', () => {
expect(customerListIdentifier({ customer_type: 'individual', org_number: 'CHE-123.456.789' })).toBe('CHE-123.456.789')
expect(customerListIdentifier({ customer_type: 'individual' })).toBe('')
})
})
describe('PERSONAL_NUMBER_PLAINTEXT_RE', () => {
it('accepts the four written forms and nothing else', () => {
for (const value of ['900101-1234', '900101+1234', '19900101-1234', '9001011234', '199001011234']) {
expect(PERSONAL_NUMBER_PLAINTEXT_RE.test(value)).toBe(true)
}
// Shape only: a legal-entity orgnr with a dash has the same digit shape
// and is kept out by looksLikeSwedishPersonalNumber at the call sites,
// not by this regex.
for (const value of ['********-1234', '********-????', '19900101 1234', 'abc', '1234']) {
expect(PERSONAL_NUMBER_PLAINTEXT_RE.test(value)).toBe(false)
}
})
})
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
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', () => {
@@ -40,3 +45,37 @@ describe('looksLikeSwedishPersonalNumber', () => {
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'))
})
})
+36
View File
@@ -7,6 +7,8 @@
* half lives in lib/customers/protect-personal-number.ts, which is server-only.
*/
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
/**
* Placeholder used when a stored personal_number cannot be decrypted
* (corrupted ciphertext, a value written under a different
@@ -53,6 +55,13 @@ export function isMaskedPersonalNumber(value: unknown): boolean {
*/
export const PERSONAL_NUMBER_INPUT_RE = /^(?:(?:\d{6}|\d{8})[-+]?\d{4}|\*{8}-(?:\d{4}|\?{4}))$/
/**
* The plaintext half alone: the four accepted written forms of a personnummer
* (YYMMDD-XXXX, YYMMDD+XXXX, YYYYMMDD-XXXX, or the 10/12 digits). What a create
* path accepts.
*/
export const PERSONAL_NUMBER_PLAINTEXT_RE = /^(?:\d{6}|\d{8})[-+]?\d{4}$/
/**
* Display a personal identity number without exposing birth date or full ID.
*
@@ -67,3 +76,30 @@ export function maskCustomerPersonalNumber(value: string | null | undefined): st
const last4 = value.replace(/\D/g, '').slice(-4)
return last4.length === 4 ? `********-${last4}` : null
}
/**
* The identifier a customer LIST may show for a row, never a raw personnummer.
*
* Business rows show org_number (Bolagsverket-public). Individual rows show
* the masked personal_number; a legacy individual row that still carries its
* personnummer in org_number (written before the write paths started moving
* it, see lib/customers/personal-number-shape.ts) shows that value masked the
* same way instead of raw. Callers pass rows whose personal_number is already
* the API's masked form or a plaintext value; ciphertext must be masked
* server-side first (maskStoredCustomerPersonalNumber).
*/
export function customerListIdentifier(row: {
customer_type?: string | null
org_number?: string | null
personal_number?: string | null
}): string {
if (row.customer_type !== 'individual') {
return row.org_number || row.personal_number || ''
}
if (row.personal_number) return maskCustomerPersonalNumber(row.personal_number) ?? ''
const orgNumber = row.org_number || ''
if (orgNumber && looksLikeSwedishPersonalNumber(orgNumber)) {
return maskCustomerPersonalNumber(orgNumber) ?? ''
}
return orgNumber
}
+39
View File
@@ -37,3 +37,42 @@ export function looksLikeSwedishPersonalNumber(value: string): boolean {
const birthDay = day > 60 ? day - 60 : day
return birthDay >= 1 && birthDay <= 31
}
/**
* True when a customer row's org_number is really its personnummer: the row
* is an individual (privatperson) and the value has personnummer shape.
*
* Every write path treats that combination as "personnummer submitted in the
* wrong field": the value is moved into personal_number (encrypted, masked on
* read) and org_number is left empty. Nothing masks org_number, so leaving it
* there is exactly the unmasked-identifier leak the business-type guard above
* exists to prevent; and the MCP create tool had no personal_number input at
* all until 2026-08-21, so agents had nowhere else to put it.
*/
export function orgNumberHoldsPersonalNumber(
customerType: string | null | undefined,
orgNumber: string | null | undefined,
): boolean {
return (
customerType === 'individual'
&& typeof orgNumber === 'string'
&& orgNumber.trim() !== ''
&& looksLikeSwedishPersonalNumber(orgNumber)
)
}
/**
* The personnummer as it should be stored once it has been lifted out of
* org_number: separators are kept (the encrypt path accepts any of the four
* input forms) but whitespace is dropped, because "19900101 1234" passes the
* shape check yet fails PERSONAL_NUMBER_INPUT_RE and the legacy-plaintext
* reveal regex.
*/
export function normalizeReroutedPersonalNumber(orgNumber: string): string {
return orgNumber.replace(/\s+/g, '')
}
/** Digits only, for comparing a personnummer across its written forms. */
export function personalNumberDigits(value: string): string {
return value.replace(/\D/g, '')
}
+7
View File
@@ -2409,6 +2409,13 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
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_PERSONAL_NUMBER_CONFLICT: {
httpStatus: 400,
message_sv:
'Kunden fick två olika personnummer: ett i fältet personnummer och ett i fältet organisationsnummer. En privatperson har sitt personnummer i fältet personnummer; lämna organisationsnumret tomt.',
message_en:
'The customer was given two different personal identity numbers: one in personal_number and one in org_number. An individual customer keeps its personnummer in personal_number; leave org_number empty.',
},
ARTICLE_DELETE_FAILED: {
httpStatus: 500,
message_sv: 'Artikeln kunde inte tas bort.',
@@ -14,6 +14,7 @@ import {
makeSupplierInvoice,
} from '@/tests/helpers'
import type { PendingOperation } from '@/types'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/period-service')>(
@@ -277,6 +278,80 @@ describe('commitPendingOperation: create_customer', () => {
expect(result.status).toBe('committed')
expect(findCall('customers', 'insert')?.[0]).toMatchObject({ customer_number: null })
})
// Synthetic personnummer, never a real one. Ciphertext shape enforced by
// customers_personal_number_check (20260726110000).
const PERSONAL_NUMBER = '19900101-1234'
const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/
it('stores the staged personal_number_encrypted as the customer personal_number, with the staged payment terms', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// payment_terms were resolved at staging, so no company_settings read here
enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
const encrypted = encryptPersonnummer(PERSONAL_NUMBER)
const op = makePendingOp({
operation_type: 'create_customer',
params: {
name: 'Anna Andersson',
customer_type: 'individual',
payment_terms: 10,
personal_number_encrypted: encrypted,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(findCall('customers', 'insert')?.[0]).toMatchObject({
personal_number: encrypted,
org_number: null,
default_payment_terms: 10,
})
})
it('moves a personnummer staged as org_number on an individual into personal_number, encrypted', async () => {
// An operation staged before gnubok_create_customer had a personal_number
// input, committed after this deploy.
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // company_settings read (payment-terms default)
enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
const op = makePendingOp({
operation_type: 'create_customer',
params: { name: 'Bertil Bengtsson', customer_type: 'individual', org_number: PERSONAL_NUMBER },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const inserted = findCall('customers', 'insert')?.[0] as { org_number: string | null; personal_number: string | null }
expect(inserted.org_number).toBeNull()
expect(inserted.personal_number).toMatch(CIPHERTEXT_SHAPE)
expect(decryptPersonnummer(inserted.personal_number!)).toBe(PERSONAL_NUMBER)
expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER)
})
it('still refuses a personnummer-shaped org_number on a business customer', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
operation_type: 'create_customer',
params: { name: 'Enskild Firma X', customer_type: 'swedish_business', org_number: PERSONAL_NUMBER },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(findCall('customers', 'insert')).toBeUndefined()
})
})
describe('commitPendingOperation: credit-note issuance guard', () => {
+23 -2
View File
@@ -25,7 +25,12 @@ import {
import { roundOre } from '@/lib/money'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import {
looksLikeSwedishPersonalNumber,
normalizeReroutedPersonalNumber,
orgNumberHoldsPersonalNumber,
} from '@/lib/customers/personal-number-shape'
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms'
import {
normalizeVatRateToDecimal,
@@ -359,7 +364,7 @@ async function commitCreateCustomer(
// Same GDPR guard as CreateCustomerSchema: identifiers are only masked on
// customer_type='individual' rows, so a personnummer stored as a business
// org_number would be shown unmasked everywhere.
const orgNumber = (params.org_number as string) || null
let orgNumber = (params.org_number as string) || null
if (
orgNumber &&
params.customer_type !== 'individual' &&
@@ -373,6 +378,21 @@ async function commitCreateCustomer(
}
}
// The personnummer arrives from staging already encrypted
// (personal_number_encrypted; params never hold the plaintext). An
// operation staged before gnubok_create_customer had a personal_number
// input may still carry the personnummer in org_number on an individual:
// it is stored encrypted in personal_number and org_number is cleared,
// same as every other write path.
let personalNumberEncrypted =
typeof params.personal_number_encrypted === 'string' && params.personal_number_encrypted
? params.personal_number_encrypted
: null
if (orgNumberHoldsPersonalNumber(params.customer_type as string, orgNumber)) {
personalNumberEncrypted ??= encryptCustomerPersonalNumber(normalizeReroutedPersonalNumber(orgNumber!))
orgNumber = null
}
// Unset payment terms follow the company's own default, not a hardcoded 30.
const defaultPaymentTerms = await resolveDefaultPaymentTerms(
supabase,
@@ -391,6 +411,7 @@ async function commitCreateCustomer(
email: (params.email as string) || null,
org_number: orgNumber,
vat_number: (params.vat_number as string) || null,
personal_number: personalNumberEncrypted,
default_payment_terms: defaultPaymentTerms,
address_line1: (params.address as string) || null,
postal_code: (params.postal_code as string) || null,