Files
accounted/skills/accounted-api/references/customers.md
T
13b69a2056 fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)
* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings

Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and
the v1 REST API, but not the MCP path, and the web customer list still
showed a personnummer raw when it sat in org_number.

Personnummer (MCP + every write path):
- gnubok_create_customer gets a personal_number input. Until now it had
  none, so an agent creating a private person either dropped the number
  or put it in org_number, which nothing masks. Encrypted at staging
  (personal_number_encrypted + personal_number_masked; personal_number is
  now a forbidden staging key in staging-pii-guard), the approval preview
  shows ********-1234, commitCreateCustomer stores the ciphertext as-is.
  Idempotency hashes the masked preview (new StageOptions.idempotencyParams)
  because the random-IV ciphertext would make identical retries look like
  payload changes.
- A personnummer-shaped org_number on customer_type=individual is the
  personnummer in the wrong field: it is moved into personal_number
  (encrypted) and org_number cleared, on CreateCustomerSchema (web POST,
  v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer
  for in-flight ops. Only a DIFFERENT personnummer next to personal_number
  is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type
  guard from #1724 is unchanged and now also fires at MCP staging, so the
  user never approves an operation that fails at commit.
- Read side: the web customer list and gnubok_list_customers mask a legacy
  individual row's org_number personnummer instead of showing it raw;
  list_customers exposes personal_number_masked and never the ciphertext.
- scripts/repair-customer-personal-number-in-org-number.ts moves the
  existing rows (dry run: 134 rows across 10 companies on prod); run by
  hand with --confirm after deploy.
- customer-onboarding skill: EF customers follow the #1724 decision
  (individual + personal_number); ROT/RUT section names the real field.

Payment terms (MCP):
- gnubok_create_customer staged `payment_terms || 30`, so
  resolveDefaultPaymentTerms at commit always saw 30 and the company's
  invoice_default_days never reached MCP customers. Resolved at staging
  now, so the preview shows the value the row will get.

tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first,
rationale in payload-size.bench.test.ts). apiskill regenerated; no
migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk

* fix(scripts): literal update payloads in the personnummer repair script

The no-phantom-columns scanner counts a runtime-built update payload as
unresolvable and the ceiling (379) had no headroom; two literal payloads
keep the guard able to resolve both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbLqn9bgZ9NJ5qnZMeC1Bk

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:17 +02:00

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.
  • A personnummer-shaped org_number on customer_type=individual is treated as the personnummer submitted in the wrong field: it is stored encrypted as personal_number, returned masked (********-1234), and org_number is left empty. Prefer passing it as personal_number. Next to a different personal_number in the same body it is a 400.
  • An org_number shaped like a Swedish personnummer is rejected for business customer_types: create the customer as customer_type=individual with personal_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). On an individual it is the personnummer in the wrong field: it is stored encrypted as personal_number and org_number is cleared; next to a different personal_number in the same body it is 400 CUSTOMER_PERSONAL_NUMBER_CONFLICT.
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[]
  }
}