From e6c4fe2cf8548760bfaf9d0c4f627d4f49ea3edc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 10:41:24 +0200 Subject: [PATCH] fix(customers): personnummer guard + personal_number on v1 + payment terms from settings (#1724) * fix(customers): stop personnummer landing unmasked as org_number, persist personal_number on v1, default payment terms from settings Closes #1707. Closes #1708. Personnummer (#1707, Discord kalletoxic): - CreateCustomerSchema rejects an org_number shaped like a Swedish personal identity number on business customer_types. Only customer_type=individual rows are masked in lists, so accepting one stored an unmasked personal identifier (GDPR art. 5.1 c). The shape check uses the month-position rule (legal-entity orgnr always carries >= 20), so real orgnr can never false-positive. - The v1 create, v1 PATCH and bulk-create endpoints accepted personal_number through the shared schema but silently dropped it. They now store it encrypted, expose it masked (********-1234) on the single-customer surfaces, and treat the masked form as unchanged, mirroring the internal routes. - Route-level guards on both PATCH routes (new 400 CUSTOMER_ORG_NUMBER_IS_PERSONAL) plus a client-side message in CustomerForm (sv + en). Payment terms (#1708, Discord kalletoxic): - New resolveDefaultPaymentTerms: provided value, else company_settings.invoice_default_days, else 30. Wired into the UI new-customer dialog, the internal POST, v1 create (incl. dry-run), bulk-create and the MCP staged create_customer. apiskill regenerated; no migrations. Co-Authored-By: Claude Fable 5 * docs: record what the CI build OOM actually was main raised the build heap to 8192 in parallel with this branch, so the fix itself is already in and this keeps it untouched. What was missing is the diagnosis. Measured with tsc --noEmit --extendedDiagnostics, type-checking the repo needs 4 192 550 K at 506d030b and 4 187 096 K on this branch, 5 MB less and 0.26% more instantiations. So the ceiling is the type-check pass at steady state against Node 20's ~4 GB default old-space, not bundle growth and not any single PR. Worth writing down so the next person who sees "Ineffective mark-compacts near heap limit" does not go looking for it in their own diff. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 5 + app/(dashboard)/customers/page.tsx | 25 ++ app/api/customers/[id]/route.ts | 12 + app/api/customers/route.ts | 10 +- .../[id]/commit/__tests__/route.test.ts | 1 + .../[companyId]/customers/[id]/route.ts | 77 +++++- .../customers/__tests__/route.test.ts | 233 ++++++++++++++++++ .../customers/bulk-create/route.ts | 19 +- .../companies/[companyId]/customers/route.ts | 39 ++- components/customers/CustomerForm.tsx | 17 ++ lib/api/schemas.ts | 18 ++ .../__tests__/default-payment-terms.test.ts | 42 ++++ .../__tests__/personal-number-shape.test.ts | 42 ++++ lib/customers/default-payment-terms.ts | 29 +++ lib/customers/personal-number-shape.ts | 39 +++ lib/errors/structured-errors.ts | 7 + .../__tests__/executors.test.ts | 2 + lib/pending-operations/commit.ts | 30 ++- messages/en.json | 1 + messages/sv.json | 1 + skills/accounted-api/references/customers.md | 9 + 21 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 lib/customers/__tests__/default-payment-terms.test.ts create mode 100644 lib/customers/__tests__/personal-number-shape.test.ts create mode 100644 lib/customers/default-payment-terms.ts create mode 100644 lib/customers/personal-number-shape.ts diff --git a/DECISIONS.md b/DECISIONS.md index aee9f8fa..544730b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1099,3 +1099,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] Notices Option A (contextual AttnLine slot fed by lib/notices) over a global layout notice strip: reuses convention 6 instead of adding a second piece of persistent chrome that could itself stack; Option B stays a founder call. [2026-08-19] notice_dismissals is server-side per (company, user, notice_id): dismissals must work cross-device and stay personal (a colleague still sees the notice), unlike the localStorage pattern SkatteverketPromoCard uses (left as-is here, follow-up). [2026-08-19] Notice ids embed a state discriminator (connection id + status/expiry/error timestamp) instead of a snooze timestamp: a dismissal hides exactly the state the user saw, and a NEW failure mints a new id that surfaces without any clock logic. + +[2026-08-19] Personnummer-shaped org_number is REJECTED (400) for business customer_types instead of silently accepted or soft-warned: masking only exists on individual rows, so accepting it stores an unmasked personal identifier (GDPR art. 5.1 c); the month-position rule (orgnr always >= 20) makes the shape check false-positive-free. Enskild firma customers must be created as customer_type=individual. +[2026-08-19] New-customer payment terms fall back to company_settings.invoice_default_days (then 30) in every create path (UI, internal API, v1, bulk, MCP staged op) via lib/customers/resolveDefaultPaymentTerms, rather than adding a separate customer-terms setting: one setting, one meaning, and the invoice flow already reads the same column. + +[2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected. diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 577b17f2..f2feecb6 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -69,6 +69,10 @@ function CustomersPageInner() { const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) + // Company default payment terms (Inställningar → Fakturering). Prefilled + // into the new-customer form so it opens on the company's own default + // instead of a hardcoded 30. + const [companyDefaultTerms, setCompanyDefaultTerms] = useState(null) const { toast } = useToast() const t = useTranslations('customers') const tCommon = useTranslations('common') @@ -139,6 +143,22 @@ function CustomersPageInner() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + // Best-effort: the dialog falls back to 30 until (or unless) this lands. + let cancelled = false + fetch('/api/settings') + .then((response) => (response.ok ? response.json() : null)) + .then((json) => { + if (cancelled) return + const days = json?.data?.invoice_default_days + if (typeof days === 'number' && days > 0) setCompanyDefaultTerms(days) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + async function handleCreateCustomer(data: CreateCustomerInput) { setIsCreating(true) @@ -285,6 +305,11 @@ function CustomersPageInner() { diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index f810aa99..e576c323 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -5,6 +5,7 @@ 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 { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -95,6 +96,17 @@ export const PATCH = withRouteContext( return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId }) } + // GDPR art. 5.1 c: only customer_type='individual' rows get their + // identifiers masked, so a personnummer accepted as a business + // org_number would be displayed unmasked everywhere. + if ( + body.org_number && + effectiveType !== 'individual' && + looksLikeSwedishPersonalNumber(body.org_number) + ) { + return errorResponseFromCode('CUSTOMER_ORG_NUMBER_IS_PERSONAL', opLog, { requestId }) + } + const updateData: Record = {} if (body.name !== undefined) updateData.name = body.name if (body.customer_type !== undefined) updateData.customer_type = body.customer_type diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 1982a8af..99536c3c 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -9,6 +9,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure import type { Customer } from '@/types' import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -57,6 +58,13 @@ export const POST = withRouteContext( if (!result.success) return result.response const body = result.data + // Unset payment terms follow the company's own default, not a hardcoded 30. + const defaultPaymentTerms = await resolveDefaultPaymentTerms( + supabase, + companyId!, + body.default_payment_terms, + ) + const { data, error } = await supabase .from('customers') .insert({ @@ -79,7 +87,7 @@ export const POST = withRouteContext( vat_number: body.vat_number, personal_number: encryptCustomerPersonalNumber(body.personal_number), language: body.language || 'sv', - default_payment_terms: body.default_payment_terms || 30, + default_payment_terms: defaultPaymentTerms, notes: body.notes, }) .select() diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index b4b46529..cf131b0f 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -222,6 +222,7 @@ describe('POST /api/pending-operations/:id/commit', () => { enqueueMany([ { data: pendingOp }, // fetch pending op { data: { id: 'op-1' } }, // CAS claim + { data: null, error: null }, // company_settings read (payment-terms default) { data: { id: 'cust-1', name: 'Acme AB' } }, // insert customer { data: null, error: null }, // update pending op status ]) diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index d0f98082..3f3919ea 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -21,6 +21,12 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { UpdateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { + encryptCustomerPersonalNumber, + maskCustomerRow, +} from '@/lib/customers/protect-personal-number' +import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number' +import { looksLikeSwedishPersonalNumber } 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 @@ -47,6 +53,8 @@ const CustomerDetail = z.object({ org_number: z.string().nullable(), vat_number: z.string().nullable(), vat_number_validated: z.boolean(), + // Always the masked display form ('********-1234'), never the stored value. + personal_number: z.string().nullable(), default_payment_terms: z.number(), notes: z.string().nullable(), archived_at: z.string().nullable(), @@ -60,7 +68,7 @@ const OPEN_INVOICE_STATUSES = ['sent', 'partially_paid', 'overdue'] // Explicit projection. Excludes user_id, company_id (internal scoping), // and vat_number_validated_at (internal timestamp not in the public schema). const CUSTOMER_DETAIL_COLUMNS = - 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at' + 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, personal_number, default_payment_terms, notes, archived_at, created_at, updated_at' const OPEN_INVOICE_COLUMNS = 'id, invoice_number, invoice_date, due_date, status, currency, total, remaining_amount' @@ -79,6 +87,7 @@ registerEndpoint({ pitfalls: [ 'archived_at is non-null when the customer has been soft-deleted; the customer is still queryable by id but excluded from default lists.', 'vat_number_validated reflects the last successful VIES check; it can become stale if the EU registry revokes a number.', + 'personal_number is always returned in the masked form ********-1234; the stored value is encrypted and never leaves the API.', ], example: { response: { @@ -202,7 +211,9 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } } return ok( - { ...customer, ...(invoices !== undefined ? { invoices } : {}) }, + // The selected row carries personal_number ciphertext; mask before it + // leaves the server. + { ...maskCustomerRow(customer as { personal_number?: string | null }), ...(invoices !== undefined ? { invoices } : {}) }, { requestId: ctx.requestId, partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined, @@ -230,6 +241,8 @@ registerEndpoint({ 'Idempotency-Key is mandatory; calls without it return 400.', '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).', ], example: { request: { default_payment_terms: 14, notes: 'New payment terms agreed 2026-05-12.' }, @@ -292,6 +305,51 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string } const body = parsed.data + // Mirrors the internal PATCH route: every read path returns the masked + // form ('********-1234', or '********-????' when undecryptable), so a + // client PATCHing back what it read carries no new value. A mask must + // not be validated, stored, or treated as a clear. + const personalNumberSubmitted = + body.personal_number !== undefined && !isMaskedPersonalNumber(body.personal_number) + + // The individual-only rule for personal_number and the personnummer + // guard on org_number both depend on the customer_type the row will + // have after the update; read the stored type when the body is silent. + let effectiveType: string | undefined = body.customer_type + if ( + effectiveType === undefined && + ((personalNumberSubmitted && body.personal_number) || body.org_number) + ) { + const { data: existing } = await ctx.supabase + .from('customers') + .select('customer_type') + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .maybeSingle() + effectiveType = (existing as { customer_type?: string } | null)?.customer_type + } + + if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') { + return v1ErrorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', ctx.log, { + requestId: ctx.requestId, + details: { field: 'personal_number' }, + }) + } + + // GDPR art. 5.1 c: only customer_type='individual' rows get their + // identifiers masked, so a personnummer accepted as a business + // org_number would be displayed unmasked everywhere. + if ( + body.org_number && + effectiveType !== 'individual' && + looksLikeSwedishPersonalNumber(body.org_number) + ) { + return v1ErrorResponseFromCode('CUSTOMER_ORG_NUMBER_IS_PERSONAL', 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"). @@ -323,6 +381,15 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string if (body.customer_number !== undefined) { updateData.customer_number = body.customer_number || null } + if (personalNumberSubmitted) { + // Stored as ciphertext; customers_personal_number_check accepts that + // shape only (20260726110000). + updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number) + } else if (body.customer_type !== undefined && body.customer_type !== 'individual') { + // The row is becoming a business customer: a personnummer may not + // remain stored on it (matches the internal PATCH route). + updateData.personal_number = null + } if (Object.keys(updateData).length === 0) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { @@ -352,7 +419,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + return dryRunPreview(maskCustomerRow({ ...(current as Record & { personal_number?: string | null }), ...updateData }), { requestId: ctx.requestId, log: ctx.log }) } // Best-effort VIES re-validation if vat_number is changing on an @@ -418,7 +485,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - return ok(data, { requestId: ctx.requestId }) + return ok(maskCustomerRow(data as Record & { personal_number?: string | null }), { requestId: ctx.requestId }) }, { requireIdempotencyKey: true }, ) @@ -509,7 +576,7 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin } return dryRunPreview( - { ...current, archived_at: new Date().toISOString() }, + maskCustomerRow({ ...(current as Record & { personal_number?: string | null }), archived_at: new Date().toISOString() }), { requestId: ctx.requestId, log: ctx.log }, ) } diff --git a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts index b8fa73a6..8e53a497 100644 --- a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/vat/vies-client', () => ({ })) import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { decryptPersonnummer } from '@/lib/salary/personnummer' import { GET as listCustomers, POST as createCustomer } from '../route' import { GET as getCustomer, @@ -947,3 +948,235 @@ describe('DELETE /api/v1/companies/:companyId/customers/:id', () => { expect(res.status).toBe(400) }) }) + +// ────────────────────────────────────────────────────────────────── +// personal_number handling + company payment-terms default (#1707, #1708) +// ────────────────────────────────────────────────────────────────── + +// Synthetic personnummer, never a real one. +const TEST_PERSONAL_NUMBER = '19900101-1234' +const MASKED_PERSONAL_NUMBER = '********-1234' +// Ciphertext shape enforced by customers_personal_number_check (20260726110000). +const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/ + +describe('personal_number on the v1 customer surface', () => { + it('POST rejects a personnummer-shaped org_number on a business customer', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'Enskild Firma X', + customer_type: 'swedish_business', + org_number: TEST_PERSONAL_NUMBER, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(JSON.stringify(body.error.details)).toContain('org_number') + // The customers table was never touched (idempotency bookkeeping may be). + expect(supabaseMock.from.mock.calls.some((c) => c[0] === 'customers')).toBe(false) + }) + + it('POST stores personal_number encrypted and returns it masked', 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: 'Anna Andersson', + customer_type: 'individual', + personal_number: TEST_PERSONAL_NUMBER, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const inserted = supabaseMock.captured.insert[0] as { personal_number?: string | null } + expect(inserted.personal_number).toMatch(CIPHERTEXT_SHAPE) + expect(decryptPersonnummer(inserted.personal_number!)).toBe(TEST_PERSONAL_NUMBER) + const body = await res.json() + expect(body.data.personal_number).toBe(MASKED_PERSONAL_NUMBER) + }) + + it('GET returns personal_number masked, never the stored value', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { + data: { ...SAMPLE_CUSTOMER, customer_type: 'individual', personal_number: TEST_PERSONAL_NUMBER }, + error: null, + }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await getCustomer( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.personal_number).toBe(MASKED_PERSONAL_NUMBER) + }) + + it('PATCH stores a plaintext personal_number encrypted and returns it masked', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { + data: { ...SAMPLE_CUSTOMER, customer_type: 'individual', 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}`, { + personal_number: TEST_PERSONAL_NUMBER, + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const updated = supabaseMock.captured.update[0] as { personal_number?: string | null } + expect(updated.personal_number).toMatch(CIPHERTEXT_SHAPE) + expect(decryptPersonnummer(updated.personal_number!)).toBe(TEST_PERSONAL_NUMBER) + const body = await res.json() + expect(body.data.personal_number).toBe(MASKED_PERSONAL_NUMBER) + }) + + it('PATCH treats the masked form as "leave unchanged"', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { + data: { ...SAMPLE_CUSTOMER, customer_type: 'individual', 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}`, { + personal_number: MASKED_PERSONAL_NUMBER, + notes: 'still here', + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const updated = supabaseMock.captured.update[0] as Record + expect('personal_number' in updated).toBe(false) + expect(updated.notes).toBe('still here') + }) + + it('PATCH rejects a personnummer-shaped org_number on a stored business customer', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SAMPLE_CUSTOMER, 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(400) + const body = await res.json() + expect(body.error.code).toBe('CUSTOMER_ORG_NUMBER_IS_PERSONAL') + expect(supabaseMock.captured.update).toHaveLength(0) + }) +}) + +describe('company payment-terms default on create', () => { + it('falls back to company_settings.invoice_default_days when default_payment_terms is omitted', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: { invoice_default_days: 10 }, error: null }, + customers: { data: { ...SAMPLE_CUSTOMER, default_payment_terms: 10 }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'Terms AB', + customer_type: 'swedish_business', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const inserted = supabaseMock.captured.insert[0] as { default_payment_terms?: number } + expect(inserted.default_payment_terms).toBe(10) + }) + + it('an explicit default_payment_terms wins over the company setting', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: { invoice_default_days: 10 }, error: null }, + customers: { data: { ...SAMPLE_CUSTOMER, default_payment_terms: 45 }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'Terms AB', + customer_type: 'swedish_business', + default_payment_terms: 45, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const inserted = supabaseMock.captured.insert[0] as { default_payment_terms?: number } + expect(inserted.default_payment_terms).toBe(45) + }) + + it('dry-run previews the company default without inserting', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: { invoice_default_days: 10 }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?dry_run=true`, { + name: 'Preview AB', + customer_type: 'swedish_business', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.default_payment_terms).toBe(10) + expect(supabaseMock.from.mock.calls.some((c) => c[0] === 'customers')).toBe(false) + }) +}) diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts index 2ac352da..0f9ca975 100644 --- a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -22,6 +22,8 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' +import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { eventBus } from '@/lib/events' import type { Logger } from '@/lib/logger' import type { Customer } from '@/types' @@ -117,6 +119,7 @@ async function createOneCustomer( input: z.infer, dryRun: boolean, log: Logger, + fallbackPaymentTerms: number, ): Promise { if (dryRun) { return { @@ -140,7 +143,7 @@ async function createOneCustomer( org_number: input.org_number ?? null, vat_number: input.vat_number ?? null, vat_number_validated: false, - default_payment_terms: input.default_payment_terms ?? 30, + default_payment_terms: input.default_payment_terms ?? fallbackPaymentTerms, notes: input.notes ?? null, archived_at: null, created_at: null, @@ -189,7 +192,10 @@ async function createOneCustomer( vat_number: input.vat_number ?? null, vat_number_validated: vatValidated, vat_number_validated_at: vatValidatedAt, - default_payment_terms: input.default_payment_terms ?? 30, + // Stored as ciphertext (customers_personal_number_check). The response + // projection deliberately excludes it; nothing here leaks it. + personal_number: encryptCustomerPersonalNumber(input.personal_number), + default_payment_terms: input.default_payment_terms ?? fallbackPaymentTerms, notes: input.notes ?? null, }) .select(CUSTOMER_RESPONSE_COLUMNS) @@ -296,6 +302,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( }) } + // Items without explicit payment terms follow the company's own default, + // not a hardcoded 30. Resolved once for the whole batch. + const fallbackPaymentTerms = await resolveDefaultPaymentTerms( + ctx.supabase, + ctx.companyId!, + undefined, + ) + // Sequential processing: matches /invoices/bulk-create. VIES has its own // upstream throughput limits; running a batch of 50 in parallel can trip // them. The 50-item cap keeps the worst-case latency bounded. @@ -309,6 +323,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( body.customers[i], ctx.dryRun, ctx.log, + fallbackPaymentTerms, ) results.push(item) } diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 125c3801..ba6b9b20 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -21,6 +21,12 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { + encryptCustomerPersonalNumber, + maskCustomerRow, +} from '@/lib/customers/protect-personal-number' +import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number' +import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { eventBus } from '@/lib/events' import type { Customer } from '@/types' @@ -246,6 +252,8 @@ const CustomerCreated = z.object({ org_number: z.string().nullable(), vat_number: z.string().nullable(), vat_number_validated: z.boolean(), + // Always the masked display form ('********-1234'), never the stored value. + personal_number: z.string().nullable(), default_payment_terms: z.number(), notes: z.string().nullable(), archived_at: z.string().nullable(), @@ -256,7 +264,7 @@ const CustomerCreated = z.object({ // Drop vat_number_validated_at: declared in neither CustomerCreated nor // CustomerDetail; an internal timestamp with no documented consumer. const CUSTOMER_RESPONSE_COLUMNS = - 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at' + 'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, personal_number, default_payment_terms, notes, archived_at, created_at, updated_at' registerEndpoint({ operation: 'customers.create', @@ -273,6 +281,9 @@ registerEndpoint({ '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.', + '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.', ], example: { @@ -335,6 +346,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( } const body = parsed.data + // Unset payment terms follow the company's own default, not a hardcoded + // 30. Resolved before the dry-run branch so the preview shows the value + // a commit would store. + const defaultPaymentTerms = await resolveDefaultPaymentTerms( + ctx.supabase, + ctx.companyId!, + body.default_payment_terms, + ) + // Dry-run: validate input, return the would-be record. id, timestamps, // and vat_number_validated all populate on commit, not here. if (ctx.dryRun) { @@ -357,7 +377,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( org_number: body.org_number ?? null, vat_number: body.vat_number ?? null, vat_number_validated: false, - default_payment_terms: body.default_payment_terms ?? 30, + personal_number: maskCustomerPersonalNumber(body.personal_number ?? null), + default_payment_terms: defaultPaymentTerms, notes: body.notes ?? null, archived_at: null, created_at: null, @@ -408,8 +429,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( vat_number: body.vat_number ?? null, vat_number_validated: vatValidated, vat_number_validated_at: vatValidatedAt, + // Stored as ciphertext; customers_personal_number_check accepts that + // shape only (20260726110000). Validated individual-only upstream by + // CreateCustomerSchema. + personal_number: encryptCustomerPersonalNumber(body.personal_number), language: body.language ?? 'sv', - default_payment_terms: body.default_payment_terms ?? 30, + default_payment_terms: defaultPaymentTerms, notes: body.notes ?? null, }) .select(CUSTOMER_RESPONSE_COLUMNS) @@ -429,6 +454,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) } + // The selected row carries personal_number ciphertext; nothing beyond + // this point may see it unmasked. + const safeCustomer = maskCustomerRow(data as Record & { personal_number?: string | null }) + // Emit customer.created so webhooks (Phase 2 PR-C) and downstream // handlers can react. Best-effort: emit failure does not roll back. // Cast through `unknown` because the response projection deliberately @@ -438,7 +467,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( await eventBus.emit({ type: 'customer.created', payload: { - customer: { ...(data as Record), user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Customer, + customer: { ...safeCustomer, user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Customer, companyId: ctx.companyId!, userId: ctx.userId, }, @@ -447,7 +476,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( ctx.log.warn('customer.created emit failed', err as Error) } - return created(data, { requestId: ctx.requestId }) + return created(safeCustomer, { requestId: ctx.requestId }) }, { requireIdempotencyKey: true }, ) diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index bfc79beb..20454b15 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -25,6 +25,7 @@ import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK, isMaskedPersonalNumber, } from '@/lib/customers/mask-personal-number' +import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape' import type { CreateCustomerInput } from '@/types' interface CustomerFormProps { @@ -77,6 +78,19 @@ export default function CustomerForm({ default_payment_terms: z.number().min(1).optional(), notes: z.string().optional(), }).superRefine((customer, ctx) => { + // A personnummer entered as a business org number would be shown + // unmasked in every list (only individual customers are masked). + if ( + customer.org_number && + customer.customer_type !== 'individual' && + looksLikeSwedishPersonalNumber(customer.org_number) + ) { + ctx.addIssue({ + code: 'custom', + path: ['org_number'], + message: t('org_number_looks_personal'), + }) + } const cc = parseInvoiceRecipientText(customer.invoice_email_cc_addresses ?? '') const bcc = parseInvoiceRecipientText(customer.invoice_email_bcc_addresses ?? '') for (const [field, addresses] of [ @@ -417,6 +431,9 @@ export default function CustomerForm({ placeholder={t('org_number_placeholder')} {...register('org_number')} /> + {errors.org_number && ( +

{errors.org_number.message}

+ )} {(customerType === 'eu_business' || customerType === 'non_eu_business') && ( diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 166a75a3..bb1bebb1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -22,6 +22,7 @@ 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 type { AuditAction, Currency } from '@/types' import type { BankFileFormatId } from '@/lib/import/bank-file/types' @@ -933,6 +934,23 @@ export const CreateCustomerSchema = z.object({ message: 'Personal number is only allowed for individual customers', }) } + // GDPR art. 5.1 c: a personnummer stored as a business org_number is shown + // unmasked everywhere (only customer_type='individual' rows are masked), so + // refuse to accept one silently. + if ( + customer.org_number && + customer.customer_type !== 'individual' && + looksLikeSwedishPersonalNumber(customer.org_number) + ) { + ctx.addIssue({ + code: 'custom', + path: ['org_number'], + message: + '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 list responses.', + }) + } if ( (customer.invoice_email_cc_addresses?.length ?? 0) + (customer.invoice_email_bcc_addresses?.length ?? 0) diff --git a/lib/customers/__tests__/default-payment-terms.test.ts b/lib/customers/__tests__/default-payment-terms.test.ts new file mode 100644 index 00000000..7a1c4eab --- /dev/null +++ b/lib/customers/__tests__/default-payment-terms.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' + +function makeSettingsClient(result: { data: unknown; error?: unknown }) { + const maybeSingle = vi.fn().mockResolvedValue(result) + const eq = vi.fn().mockReturnValue({ maybeSingle }) + const select = vi.fn().mockReturnValue({ eq }) + const from = vi.fn().mockReturnValue({ select }) + return { client: { from } as unknown as SupabaseClient, from } +} + +describe('resolveDefaultPaymentTerms', () => { + it('returns the provided value without touching settings', async () => { + const { client, from } = makeSettingsClient({ data: { invoice_default_days: 10 } }) + await expect(resolveDefaultPaymentTerms(client, 'company-1', 14)).resolves.toBe(14) + expect(from).not.toHaveBeenCalled() + }) + + it('falls back to company_settings.invoice_default_days when nothing is provided', async () => { + const { client } = makeSettingsClient({ data: { invoice_default_days: 10 } }) + await expect(resolveDefaultPaymentTerms(client, 'company-1', undefined)).resolves.toBe(10) + await expect(resolveDefaultPaymentTerms(client, 'company-1', null)).resolves.toBe(10) + }) + + it('falls back to 30 when the company has no setting', async () => { + const { client } = makeSettingsClient({ data: { invoice_default_days: null } }) + await expect(resolveDefaultPaymentTerms(client, 'company-1', undefined)).resolves.toBe(30) + }) + + it('falls back to 30 when the settings row is missing entirely', async () => { + const { client } = makeSettingsClient({ data: null }) + await expect(resolveDefaultPaymentTerms(client, 'company-1', undefined)).resolves.toBe(30) + }) + + it('ignores a non-positive or non-integer stored setting', async () => { + const zero = makeSettingsClient({ data: { invoice_default_days: 0 } }) + await expect(resolveDefaultPaymentTerms(zero.client, 'company-1', undefined)).resolves.toBe(30) + const frac = makeSettingsClient({ data: { invoice_default_days: 12.5 } }) + await expect(resolveDefaultPaymentTerms(frac.client, 'company-1', undefined)).resolves.toBe(30) + }) +}) diff --git a/lib/customers/__tests__/personal-number-shape.test.ts b/lib/customers/__tests__/personal-number-shape.test.ts new file mode 100644 index 00000000..535dd410 --- /dev/null +++ b/lib/customers/__tests__/personal-number-shape.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape' + +// Every personal-shaped fixture is synthetic, never a real person's number. +describe('looksLikeSwedishPersonalNumber', () => { + it('recognizes a 10-digit personnummer with and without separator', () => { + expect(looksLikeSwedishPersonalNumber('900101-1234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('9001011234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('900101+1234')).toBe(true) + }) + + it('recognizes a 12-digit personnummer for all personal centuries', () => { + expect(looksLikeSwedishPersonalNumber('19900101-1234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('199001011234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('200412241234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('189912311234')).toBe(true) + }) + + it('recognizes a samordningsnummer (day offset by 60)', () => { + expect(looksLikeSwedishPersonalNumber('19900161-1234')).toBe(true) + expect(looksLikeSwedishPersonalNumber('900191-1234')).toBe(true) + }) + + it('rejects legal-entity organisationsnummer (month position >= 20)', () => { + expect(looksLikeSwedishPersonalNumber('556677-8899')).toBe(false) + expect(looksLikeSwedishPersonalNumber('5566778899')).toBe(false) + expect(looksLikeSwedishPersonalNumber('212000-0142')).toBe(false) + expect(looksLikeSwedishPersonalNumber('165566778899')).toBe(false) + expect(looksLikeSwedishPersonalNumber('16556677-8899')).toBe(false) + }) + + it('rejects values that are neither shape', () => { + expect(looksLikeSwedishPersonalNumber('')).toBe(false) + expect(looksLikeSwedishPersonalNumber('SE556677889901')).toBe(false) + expect(looksLikeSwedishPersonalNumber('12345')).toBe(false) + expect(looksLikeSwedishPersonalNumber('19901301-1234')).toBe(false) + expect(looksLikeSwedishPersonalNumber('19900145-1234')).toBe(false) + expect(looksLikeSwedishPersonalNumber('19900199-1234')).toBe(false) + expect(looksLikeSwedishPersonalNumber('179001011234')).toBe(false) + expect(looksLikeSwedishPersonalNumber('************')).toBe(false) + }) +}) diff --git a/lib/customers/default-payment-terms.ts b/lib/customers/default-payment-terms.ts new file mode 100644 index 00000000..eec8737b --- /dev/null +++ b/lib/customers/default-payment-terms.ts @@ -0,0 +1,29 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * Resolve the payment terms for a new customer. + * + * Order: the caller-provided value, then the company's own default + * (company_settings.invoice_default_days, the same setting the invoice + * flow reads), then 30 as the last resort. Best-effort on the settings + * read: a missing row or query error falls back to 30 rather than + * failing the create. + */ +export async function resolveDefaultPaymentTerms( + supabase: SupabaseClient, + companyId: string, + provided: number | null | undefined, +): Promise { + if (typeof provided === 'number' && Number.isFinite(provided) && provided > 0) { + return provided + } + + const { data } = await supabase + .from('company_settings') + .select('invoice_default_days') + .eq('company_id', companyId) + .maybeSingle() + + const days = (data as { invoice_default_days?: number | null } | null)?.invoice_default_days + return typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : 30 +} diff --git a/lib/customers/personal-number-shape.ts b/lib/customers/personal-number-shape.ts new file mode 100644 index 00000000..aca33b75 --- /dev/null +++ b/lib/customers/personal-number-shape.ts @@ -0,0 +1,39 @@ +/** + * Shape detection for Swedish personal identity numbers submitted where an + * organisationsnummer belongs. + * + * A legal-entity organisationsnummer always carries 20 or higher in its + * "month" position (SFS 1974:174 2 §), while a personnummer has a real + * calendar month 01-12 (samordningsnummer offsets the day by 60 instead). + * That makes the two distinguishable without a checksum: any 10- or + * 12-digit value with a month of 01-12 and a plausible day is a personal + * identity number, never a company. + * + * Used to stop a personnummer from being stored as a business org_number, + * where nothing masks it: list responses only mask identifiers on + * customer_type='individual' rows (GDPR art. 5.1 c data minimisation). + * + * Deliberately crypto-free so the client form, the Zod schemas and the + * server routes can all share it, same as mask-personal-number.ts. + */ +export function looksLikeSwedishPersonalNumber(value: string): boolean { + const digits = value.replace(/[\s+-]/g, '') + if (!/^(\d{10}|\d{12})$/.test(digits)) return false + + if (digits.length === 12) { + // 12-digit organisationsnummer are written with a '16' century prefix + // (Skatteverket convention); personnummer centuries are 18/19/20. + const century = digits.slice(0, 2) + if (century !== '18' && century !== '19' && century !== '20') return false + } + + const body = digits.length === 12 ? digits.slice(2) : digits + const month = parseInt(body.slice(2, 4), 10) + const day = parseInt(body.slice(4, 6), 10) + + if (month < 1 || month > 12) return false + + // Day 1-31 for a personnummer, 61-91 for a samordningsnummer (+60 offset). + const birthDay = day > 60 ? day - 60 : day + return birthDay >= 1 && birthDay <= 31 +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 99cdff9b..f6f2c178 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2234,6 +2234,13 @@ const ARTICLE: Record = { message_sv: 'Personnummer kan endast sparas för privatkunder.', message_en: 'Personal numbers can only be stored for individual customers.', }, + CUSTOMER_ORG_NUMBER_IS_PERSONAL: { + httpStatus: 400, + message_sv: + 'Organisationsnumret ser ut som ett personnummer. Spara kunden som privatperson i stället, så lagras numret skyddat och maskeras i listor.', + 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.', + }, ARTICLE_DELETE_FAILED: { httpStatus: 500, message_sv: 'Artikeln kunde inte tas bort.', diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 0deb77e8..fc22a23c 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -210,6 +210,7 @@ describe('commitPendingOperation: create_customer', () => { it('inserts the staged customer_number', async () => { 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_number: 'K-1001' }), error: null, @@ -262,6 +263,7 @@ describe('commitPendingOperation: create_customer', () => { it('inserts customer_number as null when not staged', async () => { 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' }), error: null }) // customers insert enqueue({ data: null, error: null }) // dispatcher's pending_operations update diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e48903fb..25ce8b8b 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -25,6 +25,8 @@ 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 { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { normalizeVatRateToDecimal, normalizeVatRateToFraction, @@ -354,6 +356,30 @@ async function commitCreateCustomer( return { error: 'customer_number must be a string of at most 32 characters', status: 400 } } + // 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 + if ( + orgNumber && + params.customer_type !== 'individual' && + looksLikeSwedishPersonalNumber(orgNumber) + ) { + return { + error: + 'org_number ser ut som ett personnummer. Skapa kunden som privatperson ' + + '(customer_type=individual) i stället, så maskeras numret i listor.', + status: 400, + } + } + + // Unset payment terms follow the company's own default, not a hardcoded 30. + const defaultPaymentTerms = await resolveDefaultPaymentTerms( + supabase, + companyId, + typeof params.payment_terms === 'number' ? params.payment_terms : undefined, + ) + const { data, error } = await supabase .from('customers') .insert({ @@ -363,9 +389,9 @@ async function commitCreateCustomer( customer_type: params.customer_type as string, customer_number: customerNumber || null, email: (params.email as string) || null, - org_number: (params.org_number as string) || null, + org_number: orgNumber, vat_number: (params.vat_number as string) || null, - default_payment_terms: (params.payment_terms as number) || 30, + default_payment_terms: defaultPaymentTerms, address_line1: (params.address as string) || null, postal_code: (params.postal_code as string) || null, city: (params.city as string) || null, diff --git a/messages/en.json b/messages/en.json index 9c37fabc..b26f2269 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1370,6 +1370,7 @@ "personal_number_invalid": "Invalid personal number", "org_number_label": "Org. number", "org_number_placeholder": "XXXXXX-XXXX", + "org_number_looks_personal": "This looks like a Swedish personal identity number. Choose the customer type Individual instead, so it is stored protected and masked in lists.", "vat_label": "VAT number", "vat_placeholder_eu": "DE123456789", "vat_placeholder_se": "SE123456789001", diff --git a/messages/sv.json b/messages/sv.json index c29088ee..ced196b8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1370,6 +1370,7 @@ "personal_number_invalid": "Ogiltigt personnummer", "org_number_label": "Organisationsnummer", "org_number_placeholder": "XXXXXX-XXXX", + "org_number_looks_personal": "Numret ser ut som ett personnummer. Välj kundtypen Privatperson i stället, så lagras det skyddat och maskeras i listor.", "vat_label": "VAT-nummer (momsreg.nr)", "vat_placeholder_eu": "DE123456789", "vat_placeholder_se": "SE123456789001", diff --git a/skills/accounted-api/references/customers.md b/skills/accounted-api/references/customers.md index 708c05cb..75535f75 100644 --- a/skills/accounted-api/references/customers.md +++ b/skills/accounted-api/references/customers.md @@ -94,6 +94,9 @@ Creates a new customer for the company. Requires Idempotency-Key (UUID). Support - 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. +- 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. | Parameter | In | Type | Required | Notes | @@ -146,6 +149,7 @@ Response `200`: org_number: string, vat_number: string, vat_number_validated: boolean, + personal_number: string, default_payment_terms: number, notes: string, archived_at: string, @@ -177,6 +181,7 @@ Returns the full customer record. Pass ?expand=invoices to embed any open invoic **Pitfalls:** - archived_at is non-null when the customer has been soft-deleted; the customer is still queryable by id but excluded from default lists. - vat_number_validated reflects the last successful VIES check; it can become stale if the EU registry revokes a number. +- personal_number is always returned in the masked form ********-1234; the stored value is encrypted and never leaves the API. | Parameter | In | Type | Required | Notes | |---|---|---|---|---| @@ -204,6 +209,7 @@ Response `200`: org_number: string, vat_number: string, vat_number_validated: boolean, + personal_number: string, default_payment_terms: number, notes: string, archived_at: string, @@ -236,6 +242,8 @@ Patches the customer with the supplied fields. All fields optional. Idempotent ( - Idempotency-Key is mandatory; calls without it return 400. - 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). | Parameter | In | Type | Required | Notes | |---|---|---|---|---| @@ -288,6 +296,7 @@ Response `200`: org_number: string, vat_number: string, vat_number_validated: boolean, + personal_number: string, default_payment_terms: number, notes: string, archived_at: string,