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
@@ -1151,6 +1151,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-21] RIP-4 cascade step 3 (UI): the AI booking proposal is surfaced INSIDE the existing QuickReviewDialog rather than a new inline-row card, so it reuses that dialog's proven, deterministic, balanced commit path (POST /api/transactions/[id]/categorize) instead of a parallel one. components/transactions/AiCategorizeProposal.tsx fetches POST /api/agent/categorize on dialog open (keyed on tx.id so it remounts per transaction), pre-fills accountOverride + vatTreatment via handleAccountChange (class-2 VAT clearing preserved), and shows the confidence band (säker/trolig/välj konto) + "Varför" + the candidate alternatives (click to re-apply). Falls back SILENTLY to the deterministic defaults on error, and shows a soft note on 503 (ai_unconfigured) — the dialog always works without AI. NO silent auto-posting (founder call, avoids the storno-on-undo mess): "säker" = pre-filled, one-tap Bokför via the dialog's existing button; true hands-off auto-book waits for calibration. i18n: strings inline Swedish for now (assistant surface), lift to messages/{sv,en}.json before final merge. Confidence bands (0.8/0.5) are placeholders until calibration. Needs founder visual sign-off before merge ([[project_nav_ia_redesign]]).
|
||||
[2026-08-21] RIP-4 step 4 = calibration. lib/agent/categorize/calibration.ts is the engine: isotonic regression via pool-adjacent-violators (distribution-free, monotonic) over (confidence, was_correct) samples → a calibrator that turns raw selector confidence into a probability that actually means what it says; plus reliabilityByBucket/ECE and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap (default 2000 kr) — so "säker" stays honest until proven. Measurement loop: migration 20260821100000 categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]); POST /api/agent/categorize/outcome logs one sample (proposed vs booked account → was_correct) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped to keep the corpus clean); AiCategorizeProposal surfaces the proposal metadata via onProposal. scripts/fit-categorize-calibration.ts (READ-ONLY) prints the reliability diagram + ECE + fitted calibrator once data exists — run it in a few weeks, then store the calibrator/thresholds where bandFor reads them and only THEN consider enabling auto-book. Fitting needs >=200 real samples so nothing calibrates today; the loop just starts collecting. Migration applies on merge (auto-apply-on-merge active) — not applied manually.
|
||||
[2026-08-21] The categorize selector now reads the underlag, not just the bank line — the highest-leverage quality lever for the cold-start majority (prod: 365 companies with 32.7k unbooked tx, median 0 templates, so the LLM carries them). lib/agent/categorize/underlag.ts gathers the matched receipt/invoice text (receipts.matched_transaction_id + invoice_inbox_items.matched_transaction_id + the transaction's own document_attachments), rendered as bounded Swedish text (supplier, date, total, moms, line items). Core reads these tables directly via supabase (table names, not @/extensions imports — the tables live in the shared DB). POST /api/agent/categorize gathers it server-side when the caller didn't pass `underlag`, so the model sees the actual supplier + line items. Best-effort ('' on any failure); server-side only, no client change (so no conflict with the calibration PR #1784 which also touches the dialog). Prod read (project pwxtzglxptnnvjrpixpg) also confirmed: 3342 active counterparty templates / 26k occurrences → established users get strong instant candidates; NO backfill needed (templates already reflect historical bookings).
|
||||
[2026-08-21] A personnummer-shaped org_number on customer_type=individual is MOVED into personal_number (encrypted, masked on read) and org_number cleared, on every write path (CreateCustomerSchema transform for web/v1/bulk, both PATCH routes, MCP gnubok_create_customer at staging and commitCreateCustomer for in-flight ops), rather than rejected like the business-type case (#1724): the value is unambiguously the person's own personnummer, the MCP tool had no personal_number input until now so agents had nowhere else to put it (134 such rows across 10 companies on prod), and the v1 docs promised org_number was accepted for individuals, so a 400 would break live clients for no privacy gain. Only an org_number that is a DIFFERENT personnummer than a submitted personal_number is refused (CUSTOMER_PERSONAL_NUMBER_CONFLICT). MCP stages the personnummer encrypted (personal_number_encrypted + personal_number_masked, personal_number is now a forbidden staging key) and hashes the masked preview for idempotency, since the random-IV ciphertext would make identical retries look like payload changes. Read surfaces (web customer list, MCP list_customers) mask a legacy individual row's org_number personnummer instead of showing it raw; scripts/repair-customer-personal-number-in-org-number.ts moves the existing rows and is run by hand with --confirm after deploy. MCP create_customer payment_terms are resolved from company_settings at staging: the staging-side || 30 is why #1708's fix never reached MCP customers.
|
||||
[2026-08-21] Peppol receiving (PR2) keeps Qvalia's consolidated partner account: every company's 0007:orgnr is registered on OUR account (PUT /partner/{p}/account/{p}/peppol/{id}) and inbound documents are routed by the AccountingCustomerParty EndpointID through peppol_registrations, because Qvalia confirmed sending needs no per-company account and child accounts would only add a 100 kr/mån tenant fee per customer; the exact received UBL XML is archived as a WORM document (upload_source e_invoice, extractionOwner none) and the inbox row is filled from the structured UBL with confidence 1 (no model pass), following the mail-hunt precedent of core inserting invoice_inbox_items directly; personnummer-based companies are refused registration until 0088 GLN support exists (publishing them would put personal data in the Peppol Directory); the poll is a 10-minute cron (GET .../readinvoices marks documents read at Qvalia, so every fetched document is archived before anything else can fail).
|
||||
[2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math.
|
||||
[2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
|
||||
import { customerListIdentifier } from '@/lib/customers/mask-personal-number'
|
||||
|
||||
const CustomerForm = dynamic(
|
||||
() => import('@/components/customers/CustomerForm'),
|
||||
@@ -53,8 +54,11 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
|
||||
]
|
||||
const INITIAL_VISIBLE_ROWS = 100
|
||||
|
||||
// Business rows show org_number; individual rows show the masked
|
||||
// personnummer the API returns, and a legacy individual row that still
|
||||
// carries its personnummer in org_number shows that masked too, never raw.
|
||||
function getIdentifier(customer: Customer): string {
|
||||
return customer.org_number || customer.personal_number || ''
|
||||
return customerListIdentifier(customer)
|
||||
}
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
maskCustomerRow,
|
||||
} from '@/lib/customers/protect-personal-number'
|
||||
import { isMaskedPersonalNumber } 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'
|
||||
|
||||
// v1-only extension: allow PATCH to set archived_at back to null to
|
||||
// un-archive a customer. Restricted to literal `null` so the caller can't
|
||||
@@ -242,7 +247,7 @@ registerEndpoint({
|
||||
'org_number uniqueness is enforced at DB level: 23505 → 409 CUSTOMER_DUPLICATE_ORG_NUMBER.',
|
||||
'VIES re-validation is best-effort and runs only on commit. A VIES timeout does not fail the update.',
|
||||
'personal_number: a plaintext value is stored encrypted (individual customers only); the masked form a read returned (********-1234) means "leave unchanged" and is never stored; null clears it. Changing customer_type away from individual clears any stored personal_number.',
|
||||
'An org_number shaped like a Swedish personnummer is rejected for business customer_types (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL).',
|
||||
'An org_number shaped like a Swedish personnummer is rejected for business customer_types (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL). On an individual it is the personnummer in the wrong field: it is stored encrypted as personal_number and org_number is cleared; next to a different personal_number in the same body it is 400 CUSTOMER_PERSONAL_NUMBER_CONFLICT.',
|
||||
],
|
||||
example: {
|
||||
request: { default_payment_terms: 14, notes: 'New payment terms agreed 2026-05-12.' },
|
||||
@@ -350,6 +355,26 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// 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 v1ErrorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_CONFLICT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'org_number' },
|
||||
})
|
||||
}
|
||||
|
||||
// Build the partial update set. Fields explicitly set to undefined in
|
||||
// the body are not in the resulting object (Zod strips undefined). null
|
||||
// IS allowed and means "clear the field" (or, for archived_at, "un-archive").
|
||||
@@ -381,7 +406,10 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (body.customer_number !== undefined) {
|
||||
updateData.customer_number = body.customer_number || null
|
||||
}
|
||||
if (personalNumberSubmitted) {
|
||||
if (reroutedPersonalNumber) updateData.org_number = null
|
||||
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)
|
||||
|
||||
@@ -1180,3 +1180,96 @@ describe('company payment-terms default on create', () => {
|
||||
expect(supabaseMock.from.mock.calls.some((c) => c[0] === 'customers')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// 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. (The pre-2026-08-21 docs told callers
|
||||
// org_number was accepted for individuals; it still is, it just lands right.)
|
||||
describe('personnummer submitted as org_number on an individual (v1)', () => {
|
||||
it('POST stores it encrypted as personal_number and returns org_number empty', async () => {
|
||||
withWriteScope()
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: {
|
||||
data: {
|
||||
...SAMPLE_CUSTOMER,
|
||||
customer_type: 'individual',
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
personal_number: TEST_PERSONAL_NUMBER,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await createCustomer(
|
||||
makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, {
|
||||
name: 'Bertil Bengtsson',
|
||||
customer_type: 'individual',
|
||||
org_number: TEST_PERSONAL_NUMBER,
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const inserted = supabaseMock.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(TEST_PERSONAL_NUMBER)
|
||||
expect(JSON.stringify(inserted)).not.toContain(TEST_PERSONAL_NUMBER)
|
||||
const body = await res.json()
|
||||
expect(body.data.personal_number).toBe(MASKED_PERSONAL_NUMBER)
|
||||
expect(body.data.org_number).toBeNull()
|
||||
})
|
||||
|
||||
it('PATCH stores it encrypted as personal_number and clears org_number', async () => {
|
||||
withWriteScope()
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: {
|
||||
data: { ...SAMPLE_CUSTOMER, customer_type: 'individual', org_number: null, personal_number: TEST_PERSONAL_NUMBER },
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await updateCustomer(
|
||||
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
|
||||
org_number: TEST_PERSONAL_NUMBER,
|
||||
}),
|
||||
detailParams(COMPANY_ID, CUSTOMER_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const updated = supabaseMock.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(TEST_PERSONAL_NUMBER)
|
||||
})
|
||||
|
||||
it('PATCH refuses an org_number that is a different personnummer than personal_number', async () => {
|
||||
withWriteScope()
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: {
|
||||
data: { ...SAMPLE_CUSTOMER, customer_type: 'individual' },
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await updateCustomer(
|
||||
makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {
|
||||
org_number: '19850505-5555',
|
||||
personal_number: TEST_PERSONAL_NUMBER,
|
||||
}),
|
||||
detailParams(COMPANY_ID, CUSTOMER_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_CONFLICT')
|
||||
expect(supabaseMock.captured.update).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -280,8 +280,8 @@ registerEndpoint({
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.',
|
||||
'org_number uniqueness is enforced at the database level; duplicate inserts return 409 CUSTOMER_DUPLICATE_ORG_NUMBER.',
|
||||
'For Swedish sole traders (customer_type=individual), org_number IS the personnummer. List responses mask it; the create endpoint accepts it as input.',
|
||||
'An org_number shaped like a Swedish personnummer is rejected for business customer_types: create the customer as customer_type=individual (personal_number or org_number) so the number is masked and protected.',
|
||||
'A personnummer-shaped org_number on customer_type=individual is treated as the personnummer submitted in the wrong field: it is stored encrypted as personal_number, returned masked (********-1234), and org_number is left empty. Prefer passing it as personal_number. Next to a different personal_number in the same body it is a 400.',
|
||||
'An org_number shaped like a Swedish personnummer is rejected for business customer_types: create the customer as customer_type=individual with personal_number so the number is masked and protected.',
|
||||
'personal_number is accepted only for customer_type=individual, stored encrypted, and returned in the masked form ********-1234.',
|
||||
'If default_payment_terms is omitted, it defaults to the company setting invoice_default_days, falling back to 30.',
|
||||
'VIES validation runs only on commit. Dry-run skips the external call and leaves vat_number_validated=false in the preview.',
|
||||
|
||||
@@ -174,6 +174,12 @@ function CustomerPreview({ data }: { data: Record<string, unknown> }) {
|
||||
<span className="font-mono">{String(data.org_number)}</span>
|
||||
</>
|
||||
) : null}
|
||||
{data.personal_number_masked ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Personnr</span>
|
||||
<span className="font-mono">{String(data.personal_number_masked)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { hashRequest } from '@/lib/api/idempotency'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { tools } from '../server'
|
||||
|
||||
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_create_customer')!
|
||||
@@ -19,6 +21,7 @@ describe('gnubok_create_customer: customer_number input', () => {
|
||||
|
||||
it('stages the trimmed customer_number in params and preview', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // company_settings read (payment-terms default)
|
||||
enqueue({ data: { id: 'op-create-customer-1' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
@@ -76,6 +79,7 @@ describe('gnubok_create_customer: customer_number input', () => {
|
||||
|
||||
it('stages customer_number as null when omitted', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // company_settings read (payment-terms default)
|
||||
enqueue({ data: { id: 'op-create-customer-2' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
@@ -94,3 +98,270 @@ describe('gnubok_create_customer: customer_number input', () => {
|
||||
expect(inserted.params).toMatchObject({ customer_number: null })
|
||||
})
|
||||
})
|
||||
|
||||
// ── personal_number (privatperson) ────────────────────────────────────
|
||||
//
|
||||
// Synthetic personnummer, never a real one. Ciphertext shape enforced by
|
||||
// customers_personal_number_check (20260726110000).
|
||||
const PERSONAL_NUMBER = '19900101-1234'
|
||||
const MASKED = '********-1234'
|
||||
const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/
|
||||
|
||||
type StagedInsert = {
|
||||
title: string
|
||||
params: Record<string, unknown>
|
||||
preview_data: Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('gnubok_create_customer: personal_number', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('exposes personal_number in the strict input schema', () => {
|
||||
const properties = tool().inputSchema.properties as Record<string, Record<string, unknown>>
|
||||
expect(properties.personal_number).toMatchObject({ type: 'string' })
|
||||
})
|
||||
|
||||
it('stages the personnummer encrypted and previews it masked, never in plaintext', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // company_settings read (payment-terms default)
|
||||
enqueue({ data: { id: 'op-pn-1' } }) // pending_operations insert
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Anna Andersson', customer_type: 'individual', personal_number: PERSONAL_NUMBER },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.personal_number_masked).toBe(MASKED)
|
||||
expect(result.preview).not.toHaveProperty('personal_number')
|
||||
expect(result.preview).not.toHaveProperty('personal_number_encrypted')
|
||||
expect(JSON.stringify(result)).not.toContain(PERSONAL_NUMBER)
|
||||
|
||||
const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert
|
||||
expect(inserted.params.personal_number_encrypted).toMatch(CIPHERTEXT_SHAPE)
|
||||
expect(decryptPersonnummer(inserted.params.personal_number_encrypted as string)).toBe(PERSONAL_NUMBER)
|
||||
expect(inserted.params).not.toHaveProperty('personal_number')
|
||||
expect(inserted.preview_data.personal_number_masked).toBe(MASKED)
|
||||
// Nothing persisted carries the plaintext: not params, not preview, not title.
|
||||
expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER)
|
||||
})
|
||||
|
||||
it('moves a personnummer-shaped org_number on an individual into personal_number', async () => {
|
||||
// What every agent had to do before this tool had a personal_number input.
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { id: 'op-pn-2' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Bertil Bengtsson', customer_type: 'individual', org_number: PERSONAL_NUMBER },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.org_number).toBeNull()
|
||||
expect(result.preview.personal_number_masked).toBe(MASKED)
|
||||
|
||||
const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert
|
||||
expect(inserted.params.org_number).toBeNull()
|
||||
expect(decryptPersonnummer(inserted.params.personal_number_encrypted as string)).toBe(PERSONAL_NUMBER)
|
||||
expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER)
|
||||
})
|
||||
|
||||
it('refuses a personnummer-shaped org_number on a business customer before staging', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ name: 'Enskild Firma X', customer_type: 'swedish_business', org_number: PERSONAL_NUMBER },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/personnummer/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses personal_number on a business customer', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ name: 'Kund AB', customer_type: 'swedish_business', personal_number: PERSONAL_NUMBER },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/individual/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a malformed personal_number', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{ name: 'Anna Andersson', customer_type: 'individual', personal_number: 'not-a-personnummer' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/personnummer/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an org_number that is a different personnummer than personal_number', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
tool().execute(
|
||||
{
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
org_number: '19850505-5555',
|
||||
personal_number: PERSONAL_NUMBER,
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/differs/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry_run returns the masked preview without staging', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // company_settings read
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Anna Andersson', customer_type: 'individual', personal_number: PERSONAL_NUMBER, dry_run: true },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.dry_run).toBe(true)
|
||||
expect(result.preview.personal_number_masked).toBe(MASKED)
|
||||
expect(JSON.stringify(result)).not.toContain(PERSONAL_NUMBER)
|
||||
expect(findCall('pending_operations', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replays an identical retry under the same idempotency_key despite the random-IV ciphertext', async () => {
|
||||
const args = {
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number: PERSONAL_NUMBER,
|
||||
idempotency_key: '0f2b6f5e-3f2a-4b9e-9c1d-6e9c2a1b7d55',
|
||||
}
|
||||
|
||||
// First call: miss, stage, store.
|
||||
const first = createQueuedMockSupabase()
|
||||
first.enqueue({ data: null }) // company_settings read
|
||||
first.enqueue({ data: null }) // idempotency_keys lookup: miss
|
||||
first.enqueue({ data: { id: 'op-pn-idem' } }) // pending_operations insert
|
||||
first.enqueue({ data: null }) // idempotency_keys store
|
||||
const firstResult = (await tool().execute(args, 'company-1', 'user-1', first.supabase as never)) as {
|
||||
operation_id?: string
|
||||
preview: Record<string, unknown>
|
||||
}
|
||||
expect(firstResult.operation_id).toBe('op-pn-idem')
|
||||
const stored = first.findCall('idempotency_keys', 'insert')?.[0] as {
|
||||
request_hash: string
|
||||
response_body: Record<string, unknown>
|
||||
}
|
||||
// The hash is over the masked preview, which is stable across calls;
|
||||
// hashing params (random-IV ciphertext) would make every retry look like
|
||||
// a different payload and fail with IDEMPOTENCY_KEY_REUSE.
|
||||
expect(stored.request_hash).toBe(
|
||||
hashRequest({ operationType: 'create_customer', params: firstResult.preview, companyId: 'company-1' }),
|
||||
)
|
||||
expect(JSON.stringify(stored)).not.toContain(PERSONAL_NUMBER)
|
||||
|
||||
// Second call: hit with the stored hash; nothing new is staged.
|
||||
const second = createQueuedMockSupabase()
|
||||
second.enqueue({ data: null }) // company_settings read
|
||||
second.enqueue({
|
||||
data: {
|
||||
request_hash: stored.request_hash,
|
||||
response_status: 'success',
|
||||
response_body: stored.response_body,
|
||||
expires_at: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
const replay = (await tool().execute(args, 'company-1', 'user-1', second.supabase as never)) as {
|
||||
idempotency_replay?: boolean
|
||||
operation_id?: string
|
||||
}
|
||||
expect(replay.idempotency_replay).toBe(true)
|
||||
expect(replay.operation_id).toBe('op-pn-idem')
|
||||
expect(second.findCall('pending_operations', 'insert')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── payment terms ─────────────────────────────────────────────────────
|
||||
//
|
||||
// #1708: staging hardcoded `|| 30`, so the company's invoice_default_days
|
||||
// never reached customers created through MCP even after the web/v1 fix.
|
||||
describe('gnubok_create_customer: payment terms', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('defaults to the company setting when payment_terms is omitted', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: { invoice_default_days: 10 } }) // company_settings read
|
||||
enqueue({ data: { id: 'op-pt-1' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Kund AB', customer_type: 'swedish_business' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.payment_terms).toBe(10)
|
||||
const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert
|
||||
expect(inserted.params.payment_terms).toBe(10)
|
||||
})
|
||||
|
||||
it('falls back to 30 when the company has no default', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // company_settings read: no row
|
||||
enqueue({ data: { id: 'op-pt-2' } })
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Kund AB', customer_type: 'swedish_business' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { preview: Record<string, unknown> }
|
||||
|
||||
expect(result.preview.payment_terms).toBe(30)
|
||||
const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert
|
||||
expect(inserted.params.payment_terms).toBe(30)
|
||||
})
|
||||
|
||||
it('keeps an explicit payment_terms without reading the company setting', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-pt-3' } }) // only the insert: no settings read
|
||||
|
||||
const result = (await tool().execute(
|
||||
{ name: 'Kund AB', customer_type: 'swedish_business', payment_terms: 14 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.payment_terms).toBe(14)
|
||||
const inserted = findCall('pending_operations', 'insert')?.[0] as StagedInsert
|
||||
expect(inserted.params.payment_terms).toBe(14)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { tools } from '../server'
|
||||
|
||||
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_list_customers')!
|
||||
|
||||
// Synthetic personnummer, never a real one.
|
||||
const PERSONAL_NUMBER = '19900101-1234'
|
||||
const MASKED = '********-1234'
|
||||
|
||||
describe('gnubok_list_customers: individual identifiers', () => {
|
||||
it('never lists a personnummer raw: ciphertext is masked, a legacy org_number personnummer is masked and nulled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c-1', name: 'Acme AB', customer_type: 'swedish_business', org_number: '556677-8899', personal_number: null },
|
||||
{ id: 'c-2', name: 'Anna', customer_type: 'individual', org_number: null, personal_number: encryptPersonnummer(PERSONAL_NUMBER) },
|
||||
// Written before the write paths moved an individual's personnummer
|
||||
// out of org_number (the 2026-08-21 fix); the repair script moves it.
|
||||
{ id: 'c-3', name: 'Bertil', customer_type: 'individual', org_number: PERSONAL_NUMBER, personal_number: null },
|
||||
{ id: 'c-4', name: 'Cecilia', customer_type: 'individual', org_number: null, personal_number: null },
|
||||
],
|
||||
})
|
||||
|
||||
const result = (await tool().execute({}, 'company-1', 'user-1', supabase as never)) as {
|
||||
customers: Array<Record<string, unknown>>
|
||||
count: number
|
||||
}
|
||||
|
||||
expect(result.count).toBe(4)
|
||||
const byName = Object.fromEntries(result.customers.map((c) => [c.name as string, c]))
|
||||
expect(byName['Acme AB']).toMatchObject({ org_number: '556677-8899' })
|
||||
expect(byName['Acme AB']).not.toHaveProperty('personal_number')
|
||||
expect(byName['Acme AB']).not.toHaveProperty('personal_number_masked')
|
||||
expect(byName['Anna']).toMatchObject({ org_number: null, personal_number_masked: MASKED })
|
||||
expect(byName['Bertil']).toMatchObject({ org_number: null, personal_number_masked: MASKED })
|
||||
expect(byName['Cecilia']).toMatchObject({ org_number: null, personal_number_masked: null })
|
||||
// Neither the plaintext nor the ciphertext leaves the tool.
|
||||
const serialized = JSON.stringify(result)
|
||||
expect(serialized).not.toContain(PERSONAL_NUMBER)
|
||||
expect(serialized).not.toMatch(/"personal_number"/)
|
||||
})
|
||||
})
|
||||
@@ -176,9 +176,16 @@ describe('tools/list payload size guard', () => {
|
||||
// longer needs a second staged update after create. The property has
|
||||
// no description (name + maxLength are the whole contract); headroom
|
||||
// before the change was ~11 tokens, so even that minimal form crossed.
|
||||
// * 59.75K to 59.85K with personal_number on gnubok_create_customer: a
|
||||
// private person's personnummer had no input at all on the MCP path,
|
||||
// so agents put it in org_number, where nothing masks it (GDPR art.
|
||||
// 5.1 c; 134 such rows across 10 companies on prod). The property is
|
||||
// the contract; its description and the org_number/payment_terms
|
||||
// descriptions were trimmed to one short sentence first; headroom
|
||||
// before the change was ~11 tokens, so even the trimmed form crossed.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(59_750)
|
||||
expect(approxTokens).toBeLessThan(59_850)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,29 @@ describe('assertNoPlaintextPersonnummer', () => {
|
||||
).toThrow(/preview_data contains plaintext PII key "ssn"/)
|
||||
})
|
||||
|
||||
it('throws on the customers personal_number key (create_customer must stage the encrypted form)', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{ name: 'Anna Andersson', customer_type: 'individual', personal_number: '19900101-1234' },
|
||||
'params',
|
||||
),
|
||||
).toThrow(/params contains plaintext PII key "personal_number"/)
|
||||
})
|
||||
|
||||
it('allows the encrypted/masked derivatives that create_customer stages', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{
|
||||
name: 'Anna Andersson',
|
||||
customer_type: 'individual',
|
||||
personal_number_encrypted: 'ab'.repeat(40),
|
||||
personal_number_masked: '********-1234',
|
||||
},
|
||||
'params',
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows the encrypted/masked derivatives that create_employee stages', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
|
||||
@@ -73,6 +73,21 @@ import {
|
||||
} from '@/lib/reports/vat-filing-gate'
|
||||
import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
looksLikeSwedishPersonalNumber,
|
||||
normalizeReroutedPersonalNumber,
|
||||
orgNumberHoldsPersonalNumber,
|
||||
personalNumberDigits,
|
||||
} from '@/lib/customers/personal-number-shape'
|
||||
import {
|
||||
PERSONAL_NUMBER_PLAINTEXT_RE,
|
||||
maskCustomerPersonalNumber,
|
||||
} from '@/lib/customers/mask-personal-number'
|
||||
import {
|
||||
encryptCustomerPersonalNumber,
|
||||
maskStoredCustomerPersonalNumber,
|
||||
} from '@/lib/customers/protect-personal-number'
|
||||
import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms'
|
||||
import { fetchEntryLines, fetchLinesByEntryIds, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
@@ -596,6 +611,14 @@ interface StageOptions {
|
||||
* Different payload + same key returns IDEMPOTENCY_KEY_REUSE.
|
||||
*/
|
||||
idempotencyKey?: string
|
||||
/**
|
||||
* Payload hashed for the idempotency replay check instead of `params`.
|
||||
* Needed when params carry a non-deterministic derivative of the input
|
||||
* (random-IV ciphertext, see gnubok_create_customer): hashing params would
|
||||
* make an identical retry look like a different payload and fail with
|
||||
* IDEMPOTENCY_KEY_REUSE. Must itself be free of plaintext PII.
|
||||
*/
|
||||
idempotencyParams?: Record<string, unknown>
|
||||
/**
|
||||
* ISO yyyy-MM-dd date used to look up period_status before staging. When
|
||||
* provided, the response includes a `period_status` envelope so agents and
|
||||
@@ -699,7 +722,7 @@ async function stagePendingOperation(
|
||||
// same key UUID submitted under a different company is treated as a
|
||||
// fresh request, not a replay.
|
||||
const requestHash = options.idempotencyKey
|
||||
? hashRequest({ operationType, params, companyId })
|
||||
? hashRequest({ operationType, params: options.idempotencyParams ?? params, companyId })
|
||||
: null
|
||||
if (options.idempotencyKey && requestHash) {
|
||||
const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash)
|
||||
@@ -4593,12 +4616,19 @@ export const tools: McpTool[] = [
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
// Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at
|
||||
// 1000 rows. Page on the unique id, then re-sort by name for display.
|
||||
let customers: { id: string; name: string }[]
|
||||
type ListedCustomer = {
|
||||
id: string
|
||||
name: string
|
||||
customer_type: string
|
||||
org_number: string | null
|
||||
personal_number: string | null
|
||||
}
|
||||
let rows: ListedCustomer[]
|
||||
try {
|
||||
customers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) =>
|
||||
rows = await fetchAllRows<ListedCustomer>(({ from, to }) =>
|
||||
supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country')
|
||||
.eq('company_id', companyId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
@@ -4606,7 +4636,26 @@ export const tools: McpTool[] = [
|
||||
} catch (error) {
|
||||
throw new Error(`Database error: ${error instanceof Error ? error.message : 'unknown error'}`)
|
||||
}
|
||||
customers.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id))
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id))
|
||||
|
||||
// GDPR art. 5.1 c, same rule as the v1 list: an individual's
|
||||
// personnummer never leaves this tool raw. personal_number is stored as
|
||||
// ciphertext and is exposed only as personal_number_masked
|
||||
// (********-1234); a legacy individual row that still carries the
|
||||
// personnummer in org_number (written before the write paths started
|
||||
// moving it into personal_number) shows it masked the same way, and its
|
||||
// org_number is nulled rather than listed.
|
||||
const customers = rows.map(({ personal_number, ...customer }) => {
|
||||
if (customer.customer_type !== 'individual') return customer
|
||||
const legacyInOrgNumber = orgNumberHoldsPersonalNumber(customer.customer_type, customer.org_number)
|
||||
return {
|
||||
...customer,
|
||||
org_number: legacyInOrgNumber ? null : customer.org_number,
|
||||
personal_number_masked:
|
||||
maskStoredCustomerPersonalNumber(personal_number)
|
||||
?? (legacyInOrgNumber ? maskCustomerPersonalNumber(customer.org_number) : null),
|
||||
}
|
||||
})
|
||||
|
||||
return { customers, count: customers.length }
|
||||
},
|
||||
@@ -4629,9 +4678,10 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
customer_number: { type: 'string', maxLength: 32 },
|
||||
email: { type: 'string', description: 'Email address' },
|
||||
org_number: { type: 'string', description: 'Swedish org number' },
|
||||
org_number: { type: 'string', description: 'Swedish org number (business types). A personnummer belongs in personal_number.' },
|
||||
personal_number: { type: 'string', description: 'Personnummer for customer_type=individual. Encrypted at staging, masked on read.' },
|
||||
vat_number: { type: 'string', description: 'EU VAT number' },
|
||||
payment_terms: { type: 'number', description: 'Payment terms in days (default 30)' },
|
||||
payment_terms: { type: 'number', description: 'Days. Default: the company setting, else 30.' },
|
||||
address: { type: 'string', description: 'Street address' },
|
||||
postal_code: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
@@ -4672,24 +4722,83 @@ export const tools: McpTool[] = [
|
||||
throw new Error('customer_number must be at most 32 characters.')
|
||||
}
|
||||
|
||||
const params = {
|
||||
// Identifiers. A personnummer belongs in personal_number on an
|
||||
// individual and nowhere else. The business-type guard mirrors
|
||||
// CreateCustomerSchema (nothing masks org_number, GDPR art. 5.1 c); a
|
||||
// personnummer-shaped org_number on an individual is the personnummer
|
||||
// submitted in the wrong field, which is all an agent COULD do before
|
||||
// this tool had a personal_number input, so it is moved rather than
|
||||
// refused. Everything is checked here, at staging, so the user never
|
||||
// approves an operation that then fails at commit.
|
||||
const orgNumberArg = typeof args.org_number === 'string' ? args.org_number.trim() : ''
|
||||
const personalNumberArg = typeof args.personal_number === 'string' ? args.personal_number.trim() : ''
|
||||
if (orgNumberArg && customerType !== 'individual' && looksLikeSwedishPersonalNumber(orgNumberArg)) {
|
||||
throw new Error(
|
||||
'org_number looks like a Swedish personal identity number (personnummer). Create the customer with '
|
||||
+ 'customer_type "individual" and pass the number as personal_number instead, so it is stored encrypted '
|
||||
+ 'and masked in lists.',
|
||||
)
|
||||
}
|
||||
if (personalNumberArg && customerType !== 'individual') {
|
||||
throw new Error('personal_number is only allowed for customer_type "individual".')
|
||||
}
|
||||
let orgNumber: string | null = orgNumberArg || null
|
||||
let personalNumber: string | null = personalNumberArg || null
|
||||
if (orgNumberHoldsPersonalNumber(customerType, orgNumber)) {
|
||||
const rerouted = normalizeReroutedPersonalNumber(orgNumber!)
|
||||
if (personalNumber && personalNumberDigits(personalNumber) !== personalNumberDigits(rerouted)) {
|
||||
throw new Error(
|
||||
'org_number looks like a personnummer and differs from personal_number. An individual customer keeps '
|
||||
+ 'its personnummer in personal_number; leave org_number empty.',
|
||||
)
|
||||
}
|
||||
personalNumber = personalNumber ?? rerouted
|
||||
orgNumber = null
|
||||
}
|
||||
if (personalNumber && !PERSONAL_NUMBER_PLAINTEXT_RE.test(personalNumber)) {
|
||||
throw new Error('personal_number must be a Swedish personnummer: YYYYMMDD-XXXX, YYMMDD-XXXX or the digits alone.')
|
||||
}
|
||||
|
||||
// Resolved at staging, not at commit, so the approval preview shows the
|
||||
// terms the row will actually get: the caller's value, else the
|
||||
// company's invoice_default_days, else 30. (Staging `|| 30` here is why
|
||||
// #1708's fix never reached the MCP path.)
|
||||
const paymentTermsArg = Number(args.payment_terms)
|
||||
const paymentTerms = await resolveDefaultPaymentTerms(
|
||||
supabase,
|
||||
companyId,
|
||||
Number.isFinite(paymentTermsArg) && paymentTermsArg > 0 ? paymentTermsArg : undefined,
|
||||
)
|
||||
|
||||
const preview: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
customer_type: customerType,
|
||||
customer_number: customerNumber || null,
|
||||
email: (args.email as string) || null,
|
||||
org_number: (args.org_number as string) || null,
|
||||
org_number: orgNumber,
|
||||
vat_number: (args.vat_number as string) || null,
|
||||
payment_terms: Number(args.payment_terms) || 30,
|
||||
payment_terms: paymentTerms,
|
||||
address: (args.address as string) || null,
|
||||
postal_code: (args.postal_code as string) || null,
|
||||
city: (args.city as string) || null,
|
||||
country: (args.country as string) || 'Sweden',
|
||||
// The preview (and the approval UI that renders it) only ever sees
|
||||
// the masked form.
|
||||
personal_number_masked: personalNumber ? maskCustomerPersonalNumber(personalNumber) : null,
|
||||
}
|
||||
// PII rule (staging-pii-guard): pending_operations.params never holds
|
||||
// a plaintext personnummer. Encrypted here, same as create_employee;
|
||||
// the executor stores the ciphertext as-is.
|
||||
const { personal_number_masked: _masked, ...paramsBase } = preview
|
||||
const params: Record<string, unknown> = {
|
||||
...paramsBase,
|
||||
personal_number_encrypted: personalNumber ? encryptCustomerPersonalNumber(personalNumber) : null,
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_customer',
|
||||
`Ny kund: ${params.name}`,
|
||||
`Ny kund: ${preview.name as string}`,
|
||||
params,
|
||||
params, // params ARE the preview for customers
|
||||
preview,
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, you can invoice this customer with gnubok_create_invoice using the returned customer_id.',
|
||||
@@ -4698,6 +4807,9 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
// The ciphertext has a random IV, so params differ on every call;
|
||||
// the masked preview is the stable identity of the request.
|
||||
idempotencyParams: preview,
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -21,8 +21,9 @@ later: much cheaper to ask the customer once at onboarding.
|
||||
|
||||
| Customer | customer_type | Required fields | VAT treatment |
|
||||
|---|---|---|---|
|
||||
| Swedish private person | \`individual\` | name | 25/12/6 % standard, no reverse charge |
|
||||
| Swedish AB / HB / KB / EF | \`swedish_business\` | name, org_number | 25/12/6 % standard |
|
||||
| Swedish private person | \`individual\` | name, personal_number (optional; encrypted, masked on read) | 25/12/6 % standard, no reverse charge |
|
||||
| Swedish AB / HB / KB | \`swedish_business\` | name, org_number | 25/12/6 % standard |
|
||||
| Swedish enskild firma (EF) | \`individual\` | name, personal_number (an EF's org number IS the owner's personnummer; a business type refuses it) | 25/12/6 % standard |
|
||||
| EU company (VAT-registered) | \`eu_business\` | name, vat_number (validated) | Reverse charge (varor + tjänster): invoice has 0 % VAT, customer pays VAT in their country |
|
||||
| EU company (no VAT number) | \`eu_business\` | name, country | Treated as individual: 25 % charged, no reverse charge |
|
||||
| Non-EU company | \`non_eu_business\` | name, country | Export 0 % (varor) or reverse charge per service rules |
|
||||
@@ -102,7 +103,7 @@ ROT/RUT-avdrag applies to physical persons receiving home-services
|
||||
required on the invoice. Update the customer record with:
|
||||
|
||||
- \`fastighetsbeteckning\` (property identifier, for ROT)
|
||||
- \`personnummer\` (encrypted at rest)
|
||||
- \`personal_number\` (set at creation via \`gnubok_create_customer\` or in the web form; encrypted at rest, masked on read)
|
||||
|
||||
Then create the invoice with ROT/RUT flag set. See the \`invoicing-rules\` skill
|
||||
for the booking details (BAS accounts 1513 + 3740).
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* pending_operations.params and .preview_data are persisted verbatim and
|
||||
* rendered in approval UIs, so no staging payload may carry a plaintext
|
||||
* personnummer. The one tool that legitimately receives one
|
||||
* (gnubok_create_employee) encrypts at staging time and stores only
|
||||
* `personnummer_encrypted` / `personnummer_last4` / `personnummer_masked`.
|
||||
* personnummer. The tools that legitimately receive one
|
||||
* (gnubok_create_employee, gnubok_create_customer) encrypt at staging time and
|
||||
* store only `personnummer_encrypted` / `personnummer_last4` /
|
||||
* `personnummer_masked` (employees) or `personal_number_encrypted` /
|
||||
* `personal_number_masked` (customers).
|
||||
* This guard runs inside stagePendingOperation, so every current and FUTURE
|
||||
* staging tool inherits the rule: a tool that forgets to encrypt fails loudly
|
||||
* at staging instead of silently persisting PII.
|
||||
@@ -16,7 +18,15 @@
|
||||
* forms above are therefore allowed by construction.
|
||||
*/
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['personnummer', 'pnr', 'social_security_number', 'ssn'])
|
||||
const FORBIDDEN_KEYS = new Set([
|
||||
'personnummer',
|
||||
'pnr',
|
||||
'social_security_number',
|
||||
'ssn',
|
||||
// customers.personal_number: gnubok_create_customer stages it as
|
||||
// personal_number_encrypted + personal_number_masked, never under this key.
|
||||
'personal_number',
|
||||
])
|
||||
|
||||
/**
|
||||
* Cycle/degenerate-payload stop: staged payloads are shallow JSON. Anything
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* One-off repair: move an individual customer's personnummer out of
|
||||
* customers.org_number into customers.personal_number (encrypted).
|
||||
*
|
||||
* WHY: until 2026-08-21 the MCP gnubok_create_customer tool had no
|
||||
* personal_number input, the v1 REST docs said org_number was "accepted as
|
||||
* input" for individuals, and nothing masks org_number. So a customer_type=
|
||||
* 'individual' row could carry its personnummer in org_number, where the
|
||||
* web customer list, the v1 detail endpoint and the MCP customer list showed
|
||||
* it raw (GDPR art. 5.1 c). The code fix moves such a value into
|
||||
* personal_number on every write path; this repairs the rows already in the
|
||||
* DB. Run AFTER the code fix is deployed (the read paths mask these rows in
|
||||
* the meantime, but the data should not stay there).
|
||||
*
|
||||
* Selection: customer_type='individual' AND org_number has Swedish personnummer
|
||||
* shape (lib/customers/personal-number-shape.ts). Legal-entity org numbers
|
||||
* never match (month position >= 20), so an individual carrying a real
|
||||
* organisationsnummer is left alone.
|
||||
*
|
||||
* Per row:
|
||||
* - personal_number NULL -> personal_number = encrypt(org_number), org_number = NULL
|
||||
* - personal_number already set -> org_number = NULL only (the stored
|
||||
* personnummer wins; the duplicate is reported)
|
||||
*
|
||||
* Idempotent: a repaired row no longer matches the selection, and every
|
||||
* update is guarded on the exact org_number value so a re-run or a concurrent
|
||||
* edit can never double-apply. Safe to re-run.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/repair-customer-personal-number-in-org-number.ts # dry run (read-only)
|
||||
* npx tsx scripts/repair-customer-personal-number-in-org-number.ts --confirm # performs the writes
|
||||
* add --company <uuid> to limit either mode to one company
|
||||
*
|
||||
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local;
|
||||
* --confirm additionally needs PERSONNUMMER_ENCRYPTION_KEY (the production
|
||||
* key, same one the app encrypts with). Treat .env.local as pointing at
|
||||
* PRODUCTION: the dry run is read-only; --confirm mutates PII. Never prints a
|
||||
* personnummer; rows are reported by id and company.
|
||||
*/
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { config as dotenv } from 'dotenv'
|
||||
import { resolve } from 'node:path'
|
||||
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import {
|
||||
normalizeReroutedPersonalNumber,
|
||||
orgNumberHoldsPersonalNumber,
|
||||
} from '@/lib/customers/personal-number-shape'
|
||||
|
||||
dotenv({ path: resolve(process.cwd(), '.env.local') })
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
const CONFIRM = process.argv.includes('--confirm')
|
||||
// Refuse to WRITE without the real key: encrypting with the dev fallback key
|
||||
// would make the values unreadable in production. The dry run encrypts
|
||||
// nothing, so it runs without it (the key lives in the Vercel env, not in
|
||||
// every local .env.local).
|
||||
if (CONFIRM && !process.env.PERSONNUMMER_ENCRYPTION_KEY) {
|
||||
console.error(
|
||||
'Missing PERSONNUMMER_ENCRYPTION_KEY. Refusing to write so rows are not encrypted with the dev fallback key '
|
||||
+ '(vercel env pull, or set it for this run).',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const companyFlag = process.argv.indexOf('--company')
|
||||
const ONLY_COMPANY = companyFlag >= 0 ? process.argv[companyFlag + 1] : undefined
|
||||
|
||||
const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
company_id: string
|
||||
customer_type: string
|
||||
org_number: string | null
|
||||
personal_number: string | null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const host = new URL(SUPABASE_URL!).host
|
||||
console.log(
|
||||
`Target: ${host} mode: ${CONFIRM ? 'WRITE (--confirm)' : 'DRY RUN (read-only)'}`
|
||||
+ (ONLY_COMPANY ? ` company: ${ONLY_COMPANY}` : ''),
|
||||
)
|
||||
|
||||
// Page on the PK: PostgREST caps an unranged select at 1000 rows.
|
||||
const PAGE = 1000
|
||||
const candidates: Row[] = []
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
let query = sb
|
||||
.from('customers')
|
||||
.select('id, company_id, customer_type, org_number, personal_number')
|
||||
.eq('customer_type', 'individual')
|
||||
.not('org_number', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, from + PAGE - 1)
|
||||
if (ONLY_COMPANY) query = query.eq('company_id', ONLY_COMPANY)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`select customers: ${error.message}`)
|
||||
const rows = (data ?? []) as Row[]
|
||||
candidates.push(...rows)
|
||||
if (rows.length < PAGE) break
|
||||
}
|
||||
|
||||
const affected = candidates.filter((r) => orgNumberHoldsPersonalNumber(r.customer_type, r.org_number))
|
||||
const toMove = affected.filter((r) => !r.personal_number)
|
||||
const toClear = affected.filter((r) => r.personal_number)
|
||||
const companies = new Set(affected.map((r) => r.company_id))
|
||||
|
||||
console.log(
|
||||
`Individual rows with org_number: ${candidates.length}; personnummer-shaped: ${affected.length} `
|
||||
+ `across ${companies.size} companies (move: ${toMove.length}, clear-duplicate: ${toClear.length}).`,
|
||||
)
|
||||
for (const r of affected) {
|
||||
console.log(` ${r.personal_number ? 'CLEAR' : 'MOVE '} customer ${r.id} company ${r.company_id}`)
|
||||
}
|
||||
|
||||
if (!CONFIRM) {
|
||||
console.log('Dry run: no writes performed. Re-run with --confirm to apply.')
|
||||
return
|
||||
}
|
||||
|
||||
let moved = 0
|
||||
let cleared = 0
|
||||
let skipped = 0
|
||||
for (const r of affected) {
|
||||
// Guard on the exact current value: a concurrent edit or a re-run cannot
|
||||
// double-apply, and a row that changed underneath is skipped, not clobbered.
|
||||
// Two literal payloads rather than one built at runtime, so the
|
||||
// no-phantom-columns scanner can resolve both.
|
||||
const { data, error } = r.personal_number
|
||||
? await sb
|
||||
.from('customers')
|
||||
.update({ org_number: null })
|
||||
.eq('id', r.id)
|
||||
.eq('company_id', r.company_id)
|
||||
.eq('org_number', r.org_number!)
|
||||
.select('id')
|
||||
: await sb
|
||||
.from('customers')
|
||||
.update({
|
||||
org_number: null,
|
||||
personal_number: encryptCustomerPersonalNumber(normalizeReroutedPersonalNumber(r.org_number!)),
|
||||
})
|
||||
.eq('id', r.id)
|
||||
.eq('company_id', r.company_id)
|
||||
.eq('org_number', r.org_number!)
|
||||
.select('id')
|
||||
if (error) {
|
||||
console.error(` FAILED customer ${r.id}: ${error.message}`)
|
||||
continue
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (r.personal_number) cleared += 1
|
||||
else moved += 1
|
||||
}
|
||||
console.log(`Done. moved: ${moved}, cleared duplicate org_number: ${cleared}, skipped (changed underneath): ${skipped}.`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -93,8 +93,8 @@ Creates a new customer for the company. Requires Idempotency-Key (UUID). Support
|
||||
**Pitfalls:**
|
||||
- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR.
|
||||
- org_number uniqueness is enforced at the database level; duplicate inserts return 409 CUSTOMER_DUPLICATE_ORG_NUMBER.
|
||||
- For Swedish sole traders (customer_type=individual), org_number IS the personnummer. List responses mask it; the create endpoint accepts it as input.
|
||||
- An org_number shaped like a Swedish personnummer is rejected for business customer_types: create the customer as customer_type=individual (personal_number or org_number) so the number is masked and protected.
|
||||
- A personnummer-shaped org_number on customer_type=individual is treated as the personnummer submitted in the wrong field: it is stored encrypted as personal_number, returned masked (********-1234), and org_number is left empty. Prefer passing it as personal_number. Next to a different personal_number in the same body it is a 400.
|
||||
- An org_number shaped like a Swedish personnummer is rejected for business customer_types: create the customer as customer_type=individual with personal_number so the number is masked and protected.
|
||||
- personal_number is accepted only for customer_type=individual, stored encrypted, and returned in the masked form ********-1234.
|
||||
- If default_payment_terms is omitted, it defaults to the company setting invoice_default_days, falling back to 30.
|
||||
- VIES validation runs only on commit. Dry-run skips the external call and leaves vat_number_validated=false in the preview.
|
||||
@@ -243,7 +243,7 @@ Patches the customer with the supplied fields. All fields optional. Idempotent (
|
||||
- org_number uniqueness is enforced at DB level: 23505 → 409 CUSTOMER_DUPLICATE_ORG_NUMBER.
|
||||
- VIES re-validation is best-effort and runs only on commit. A VIES timeout does not fail the update.
|
||||
- personal_number: a plaintext value is stored encrypted (individual customers only); the masked form a read returned (********-1234) means "leave unchanged" and is never stored; null clears it. Changing customer_type away from individual clears any stored personal_number.
|
||||
- An org_number shaped like a Swedish personnummer is rejected for business customer_types (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL).
|
||||
- An org_number shaped like a Swedish personnummer is rejected for business customer_types (400 CUSTOMER_ORG_NUMBER_IS_PERSONAL). On an individual it is the personnummer in the wrong field: it is stored encrypted as personal_number and org_number is cleared; next to a different personal_number in the same body it is 400 CUSTOMER_PERSONAL_NUMBER_CONFLICT.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user