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:
Jakob Wennberg
2026-08-20 10:41:24 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 9fc05c383f
commit e6c4fe2cf8
21 changed files with 643 additions and 15 deletions
@@ -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,