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,