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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user