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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a5c8f55127
commit
13b69a2056
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user