Files
accounted/lib/customers/personal-number-shape.ts
T
13b69a2056 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>
2026-08-21 18:32:17 +02:00

79 lines
3.2 KiB
TypeScript

/**
* Shape detection for Swedish personal identity numbers submitted where an
* organisationsnummer belongs.
*
* A legal-entity organisationsnummer always carries 20 or higher in its
* "month" position (SFS 1974:174 2 §), while a personnummer has a real
* calendar month 01-12 (samordningsnummer offsets the day by 60 instead).
* That makes the two distinguishable without a checksum: any 10- or
* 12-digit value with a month of 01-12 and a plausible day is a personal
* identity number, never a company.
*
* Used to stop a personnummer from being stored as a business org_number,
* where nothing masks it: list responses only mask identifiers on
* customer_type='individual' rows (GDPR art. 5.1 c data minimisation).
*
* Deliberately crypto-free so the client form, the Zod schemas and the
* server routes can all share it, same as mask-personal-number.ts.
*/
export function looksLikeSwedishPersonalNumber(value: string): boolean {
const digits = value.replace(/[\s+-]/g, '')
if (!/^(\d{10}|\d{12})$/.test(digits)) return false
if (digits.length === 12) {
// 12-digit organisationsnummer are written with a '16' century prefix
// (Skatteverket convention); personnummer centuries are 18/19/20.
const century = digits.slice(0, 2)
if (century !== '18' && century !== '19' && century !== '20') return false
}
const body = digits.length === 12 ? digits.slice(2) : digits
const month = parseInt(body.slice(2, 4), 10)
const day = parseInt(body.slice(4, 6), 10)
if (month < 1 || month > 12) return false
// Day 1-31 for a personnummer, 61-91 for a samordningsnummer (+60 offset).
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, '')
}