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