* 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>
16 KiB
Customers and articles endpoints
The customer register (bulk-create supported, archive via DELETE) and the read-only article register used for invoice line linkage.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.
GET /api/v1/companies/{companyId}/articles
List the article register (artikelregister).
scope:invoices:read · risk:low · idempotent
Returns the company's articles ordered by name. Pass ?include_inactive=true to include soft-deactivated articles. Use the returned id as items[].article_id when creating invoices; housework_type carries the ROT/RUT arbetstypskod for service articles, and revenue_account the optional BAS class-3 override.
Use when: You need the article catalog before composing invoice lines: to resolve an article_id, read its price/VAT defaults, or find ROT/RUT-tagged service articles (housework_type set). Do not use for: Creating or editing articles (dashboard-only for now). Invoice line creation itself (POST …/invoices with items[].article_id).
Pitfalls:
- Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).
- price_excl_vat always excludes VAT.
- price_excl_vat is denominated in the article's own currency, which is NOT always SEK. Check currency before copying the price onto an invoice line: the invoice carries a single currency for all its lines and there is no FX conversion here.
- housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.
- Inactive articles (active=false) are hidden by default but remain linkable for historical reads.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Response 200:
{
data: {
articles: { id: string, article_number: string, name: string, name_en: string, type: "vara" | "tjanst", unit: string, price_excl_vat: number, currency: string, vat_rate: number, revenue_account: string, cost_price: number, ean: string, housework_type: string, notes: string, active: boolean, created_at: string, updated_at: string }[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
GET /api/v1/companies/{companyId}/customers
List customers for a company.
scope:customers:read · risk:low · idempotent
Returns active customers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.
Use when: You need a customer roster: for building a UI picker, syncing a CRM, or resolving a customer_id before creating an invoice. Do not use for: Fetching a single customer you already know the id of: use GET /api/v1/companies/{companyId}/customers/{id}. Suppliers are a separate resource.
Pitfalls:
- Archived customers are hidden by default; the dashboard makes the same choice.
- org_number is included so callers can match against external CRM identifiers; for sole traders (enskild firma) it equals the personnummer.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Response 200:
{
data: { id: string, name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, archived_at: string, created_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
POST /api/v1/companies/{companyId}/customers
Create a customer.
scope:customers:write · risk:low · idempotent · dry-run · reversible
Creates a new customer for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing: the dry-run response shows the would-be record minus id and timestamps. EU-business customers with a VAT number are auto-validated against VIES on commit.
Use when: You need to register a new customer before invoicing them. Use dry-run first to catch validation errors before committing. Do not use for: Updating an existing customer (PATCH instead). Creating suppliers (different resource).
Pitfalls:
- 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 |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
name: string,
customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business",
customer_number?: string,
contact_person?: string,
email?: string,
phone?: string,
invoice_email_cc_addresses?: string[],
invoice_email_bcc_addresses?: string[],
address_line1?: string,
address_line2?: string,
postal_code?: string,
city?: string,
country?: string,
org_number?: string,
vat_number?: string,
personal_number?: string,
language?: "sv" | "en",
default_payment_terms?: number,
notes?: string
}
Response 200:
{
data: {
id: string,
name: string,
customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business",
customer_number: string,
contact_person: string,
email: string,
phone: string,
invoice_email_cc_addresses: string[],
invoice_email_bcc_addresses: string[],
address_line1: string,
address_line2: string,
postal_code: string,
city: string,
country: string,
org_number: string,
vat_number: string,
vat_number_validated: boolean,
personal_number: string,
default_payment_terms: number,
notes: string,
archived_at: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
GET /api/v1/companies/{companyId}/customers/{id}
Retrieve a single customer by id.
scope:customers:read · risk:low · idempotent
Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response.
Use when: You need the full customer record: address, payment terms, VAT validation status, contact details: before invoicing or syncing to another system. Do not use for: Listing customers (use the list endpoint). Looking up arbitrary supplier or employee records (different resources).
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 |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: {
id: string,
name: string,
customer_type: string,
customer_number: string,
contact_person: string,
email: string,
phone: string,
invoice_email_cc_addresses: string[],
invoice_email_bcc_addresses: string[],
address_line1: string,
address_line2: string,
postal_code: string,
city: string,
country: string,
org_number: string,
vat_number: string,
vat_number_validated: boolean,
personal_number: string,
default_payment_terms: number,
notes: string,
archived_at: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
PATCH /api/v1/companies/{companyId}/customers/{id}
Partially update a customer.
scope:customers:write · risk:low · idempotent · dry-run · reversible
Patches the customer with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable. When vat_number changes on an eu_business customer, VIES re-validation runs on commit (best-effort).
Use when: You need to change a customer's contact details, payment terms, address, or VAT registration. Use dry-run first to confirm the merged record before committing. Do not use for: Archiving a customer (use DELETE: sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial).
Pitfalls:
- 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 |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Request body:
{
name?: string,
customer_type?: "individual" | "swedish_business" | "eu_business" | "non_eu_business",
customer_number?: string,
contact_person?: string,
email?: string,
phone?: string,
invoice_email_cc_addresses?: string[],
invoice_email_bcc_addresses?: string[],
address_line1?: string,
address_line2?: string,
postal_code?: string,
city?: string,
country?: string,
org_number?: string,
vat_number?: string,
personal_number?: string,
language?: "sv" | "en",
default_payment_terms?: number,
notes?: string
}
Response 200:
{
data: {
id: string,
name: string,
customer_type: string,
customer_number: string,
contact_person: string,
email: string,
phone: string,
invoice_email_cc_addresses: string[],
invoice_email_bcc_addresses: string[],
address_line1: string,
address_line2: string,
postal_code: string,
city: string,
country: string,
org_number: string,
vat_number: string,
vat_number_validated: boolean,
personal_number: string,
default_payment_terms: number,
notes: string,
archived_at: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
DELETE /api/v1/companies/{companyId}/customers/{id}
Archive a customer (soft-delete).
scope:customers:write · risk:medium · idempotent · dry-run · reversible
Sets archived_at on the customer; the record is preserved (invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent: archiving an already-archived customer is a no-op. Dry-runnable.
Use when: You want to remove a customer from active rosters without losing their history. Idempotent: re-archiving is safe. Do not use for: Permanently deleting a customer with all history: the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow.
Pitfalls:
- Idempotency-Key is mandatory.
- A customer with any open invoice (sent / partially_paid / overdue) cannot be archived: returns 409 CUSTOMER_HAS_INVOICES. Issue a kreditfaktura first if you need to close the relationship cleanly. This protects ML 17 kap 24§: the customer record is the canonical source of buyer name/address for invoice reissuance.
- 204 No Content is returned on success: there is no response body to parse.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 204.
POST /api/v1/companies/{companyId}/customers/bulk-create
Create up to 50 customers in one call (partial-success).
scope:customers:write · risk:low · idempotent · dry-run · reversible
Bulk-create endpoint mirroring /invoices/bulk-create. Each customer is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.
Use when: You're importing a roster of customers from another CRM, or seeding a fresh company with its existing client list. Use dry-run first to validate the batch. Do not use for: Updating existing customers: PATCH /customers/{id} once per customer. Bulk uploads of > 50 customers: split into pages of 50. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.
Pitfalls:
- Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items.
- Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.
- org_number uniqueness is enforced at the DB level: items with duplicates fail individually with CUSTOMER_DUPLICATE_ORG_NUMBER.
- VIES validation for eu_business customers is best-effort per item; a VIES timeout leaves vat_number_validated=false but does NOT fail the item.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
customers: { name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", customer_number?: string, contact_person?: string, email?: string, phone?: string, invoice_email_cc_addresses?: string[], invoice_email_bcc_addresses?: string[], address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, personal_number?: string, language?: "sv" | "en", default_payment_terms?: number, notes?: string }[],
all_or_nothing?: boolean
}
Response 200:
{
data: {
results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[],
summary: { total: number, succeeded: number, failed: number }
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}