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
+29 -3
View File
@@ -5,7 +5,12 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import {
looksLikeSwedishPersonalNumber,
normalizeReroutedPersonalNumber,
orgNumberHoldsPersonalNumber,
personalNumberDigits,
} from '@/lib/customers/personal-number-shape'
import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -107,6 +112,23 @@ export const PATCH = withRouteContext(
return errorResponseFromCode('CUSTOMER_ORG_NUMBER_IS_PERSONAL', opLog, { requestId })
}
// The mirror image for individuals: a personnummer submitted as
// org_number is the personnummer in the wrong field. It is stored
// encrypted in personal_number and org_number is cleared, same as
// CreateCustomerSchema does on create. Next to a DIFFERENT plaintext
// personal_number in the same body the two conflict.
const reroutedPersonalNumber = orgNumberHoldsPersonalNumber(effectiveType, body.org_number)
? normalizeReroutedPersonalNumber(body.org_number!)
: null
if (
reroutedPersonalNumber
&& personalNumberSubmitted
&& body.personal_number
&& personalNumberDigits(body.personal_number) !== personalNumberDigits(reroutedPersonalNumber)
) {
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_CONFLICT', opLog, { requestId })
}
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
@@ -126,9 +148,13 @@ export const PATCH = withRouteContext(
if (body.postal_code !== undefined) updateData.postal_code = body.postal_code
if (body.city !== undefined) updateData.city = body.city
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.org_number !== undefined) {
updateData.org_number = reroutedPersonalNumber ? null : body.org_number
}
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (personalNumberSubmitted) {
if (reroutedPersonalNumber && !(personalNumberSubmitted && body.personal_number)) {
updateData.personal_number = encryptCustomerPersonalNumber(reroutedPersonalNumber)
} else if (personalNumberSubmitted) {
// Stored as ciphertext; customers_personal_number_check accepts that
// shape only (20260726110000).
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
@@ -405,3 +405,97 @@ describe('personal_number on customer routes', () => {
expect(response.status).toBe(404)
})
})
// A personnummer submitted as org_number on an individual is the personnummer
// in the wrong field: stored encrypted in personal_number, org_number left
// empty, on create and on update alike.
describe('personnummer submitted as org_number on an individual', () => {
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
captured.insert.length = 0
captured.update.length = 0
queryResult = { data: null, error: null }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('POST stores it encrypted as personal_number and leaves org_number empty', async () => {
queryResult = {
data: {
id: 'customer-1',
name: 'Bertil Bengtsson',
customer_type: 'individual',
org_number: null,
personal_number: encryptPersonnummer(PERSONAL_NUMBER),
},
error: null,
}
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Bertil Bengtsson', customer_type: 'individual', org_number: PERSONAL_NUMBER },
}),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string; org_number: string | null } }>(response)
expect(status).toBe(200)
const inserted = captured.insert[0] as { org_number?: string | null; personal_number?: string | null }
expect(inserted.org_number ?? null).toBeNull()
expect(inserted.personal_number).toMatch(CIPHERTEXT_SHAPE)
expect(decryptPersonnummer(inserted.personal_number!)).toBe(PERSONAL_NUMBER)
expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER)
expect(body.data.personal_number).toBe(MASKED)
})
it('PATCH stores it encrypted as personal_number and clears org_number', async () => {
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
org_number: null,
personal_number: encryptPersonnummer(PERSONAL_NUMBER),
},
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { org_number: PERSONAL_NUMBER },
}),
routeParams,
)
const { status } = await parseJsonResponse<unknown>(response)
expect(status).toBe(200)
const updated = captured.update[0] as { org_number?: string | null; personal_number?: string | null }
expect(updated.org_number).toBeNull()
expect(updated.personal_number).toMatch(CIPHERTEXT_SHAPE)
expect(decryptPersonnummer(updated.personal_number!)).toBe(PERSONAL_NUMBER)
})
it('PATCH refuses an org_number that is a different personnummer than personal_number', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual' },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { org_number: '19850505-5555', personal_number: PERSONAL_NUMBER },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<unknown>(response)
expect(status).toBe(400)
expect(JSON.stringify(body)).toContain('CUSTOMER_PERSONAL_NUMBER_CONFLICT')
expect(captured.update).toHaveLength(0)
})
})