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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
9fc05c383f
commit
e6c4fe2cf8
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<number> {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2234,6 +2234,13 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
|
||||
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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user