Files
accounted/app/api/v1/companies/[companyId]/customers/route.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

461 lines
19 KiB
TypeScript

/**
* /api/v1/companies/{companyId}/customers: list + create customer endpoints.
*
* GET : list with filters (customer_type, search, include_archived).
* Cursor pagination on (created_at ASC, id ASC).
* POST : create. Idempotent (mandatory Idempotency-Key). Dry-runnable
* (?dry_run=true returns validated would-be record without
* committing). VIES validation runs only on commit.
*/
import { z } from 'zod'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
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'
// Mirror the canonical CustomerTypeSchema from lib/api/schemas.ts. Only
// 'individual' refers to a natural person (Swedish sole trader / enskild
// firma); the three *_business variants are legal entities.
const CustomerType = z.enum([
'individual',
'swedish_business',
'eu_business',
'non_eu_business',
])
const CustomerSummary = z.object({
id: z.string().uuid(),
name: z.string(),
customer_type: CustomerType,
email: z.string().nullable(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
default_payment_terms: z.number(),
archived_at: z.string().nullable(),
created_at: z.string(),
})
const CustomersListResponse = listEnvelope(CustomerSummary)
// Explicit projection: never SELECT *. Schema migrations adding columns
// must update this list before the field becomes visible on the public API.
const CUSTOMER_SUMMARY_COLUMNS =
'id, name, customer_type, email, org_number, vat_number, default_payment_terms, archived_at, created_at'
registerEndpoint({
operation: 'customers.list',
method: 'GET',
path: '/api/v1/companies/:companyId/customers',
summary: 'List customers for a company.',
description:
'Returns active customers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.',
useWhen:
'You need a customer roster: for building a UI picker, syncing a CRM, or resolving a customer_id before creating an invoice.',
doNotUseFor:
'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.',
],
example: {
response: {
data: [
{
id: 'a8f1…',
name: 'Acme AB',
customer_type: 'business',
email: 'finance@acme.example',
org_number: '556677-8899',
vat_number: 'SE556677889901',
default_payment_terms: 30,
archived_at: null,
created_at: '2025-04-12T08:30:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'customers:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: CustomersListResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'customers.list',
async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
const FiltersSchema = z.object({
customer_type: CustomerType.optional(),
search: z.string().min(1).max(200).optional(),
include_archived: z.enum(['true', 'false']).optional(),
})
const filtersResult = FiltersSchema.safeParse({
customer_type: url.searchParams.get('customer_type') ?? undefined,
search: url.searchParams.get('search') ?? undefined,
include_archived: url.searchParams.get('include_archived') ?? undefined,
})
if (!filtersResult.success) {
return v1ValidationError(ctx, filtersResult.error)
}
const filters = filtersResult.data
const includeArchived = filters.include_archived === 'true'
let query = ctx.supabase
.from('customers')
.select(CUSTOMER_SUMMARY_COLUMNS)
.eq('company_id', ctx.companyId!)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (!includeArchived) {
query = query.is('archived_at', null)
}
if (filters.customer_type) {
query = query.eq('customer_type', filters.customer_type)
}
if (filters.search) {
// Build a safe ilike pattern. Two layers of escaping:
// 1. PostgREST `.or()` filter syntax uses commas + parens as
// delimiters; strip them from the user-supplied term.
// 2. SQL LIKE treats `%` and `_` (and `\` as the default escape) as
// wildcards; escape them so '100%' searches for the literal
// string '100%' rather than 'anything containing 100'.
const term = filters.search
.replace(/[,()]/g, '') // PostgREST delimiters
.replace(/[%_\\]/g, '\\$&') // LIKE wildcards
query = query.or(`name.ilike.%${term}%,org_number.ilike.${term}%`)
}
if (decoded) {
query = query.or(
`created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type Row = {
id: string
name: string
customer_type: string
email: string | null
org_number: string | null
vat_number: string | null
default_payment_terms: number
archived_at: string | null
created_at: string
} & Record<string, unknown>
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
// GDPR Art.5(1)(c) data minimisation: for sole traders (enskild firma,
// customer_type='individual'), org_number IS the personnummer: a
// directly identifying special-category identifier. Mask both
// org_number and vat_number in the LIST response so bulk fetches don't
// expose personal IDs. The DETAIL endpoint (deliberate drill-in to one
// record) still returns them. Business customers' org_numbers are
// Bolagsverket public-record data and stay visible.
//
// 'eu_individual' is retained as defense-in-depth: it's not a valid
// value in the canonical CustomerTypeSchema (so newly created customers
// can never have it), but the `customer_type` DB column has no CHECK
// constraint, so legacy rows from prior schema iterations could in
// principle carry it. Masking is free when the value never appears and
// protective if it ever does. Adding 'eu_individual' as a first-class
// customer_type for EU natural persons is a separate product decision.
const INDIVIDUAL_TYPES = new Set(['individual', 'eu_individual'])
const customers = trimmed.map((r) => {
const isIndividual = INDIVIDUAL_TYPES.has(r.customer_type)
return {
id: r.id,
name: r.name,
customer_type: r.customer_type,
email: r.email,
org_number: isIndividual ? null : r.org_number,
vat_number: isIndividual ? null : r.vat_number,
default_payment_terms: r.default_payment_terms,
archived_at: r.archived_at,
created_at: r.created_at,
}
})
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(customers, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST: create customer
// ──────────────────────────────────────────────────────────────────
const CustomerCreated = z.object({
id: z.string().uuid().nullable(),
name: z.string(),
customer_type: CustomerType,
customer_number: z.string().nullable(),
contact_person: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
invoice_email_cc_addresses: z.array(z.string()).nullable(),
invoice_email_bcc_addresses: z.array(z.string()).nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
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(),
created_at: z.string().nullable(),
updated_at: z.string().nullable(),
})
// 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, personal_number, default_payment_terms, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'customers.create',
method: 'POST',
path: '/api/v1/companies/:companyId/customers',
summary: 'Create a customer.',
description:
'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.',
useWhen:
'You need to register a new customer before invoicing them. Use dry-run first to catch validation errors before committing.',
doNotUseFor:
'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.',
],
example: {
request: {
name: 'Acme AB',
customer_type: 'swedish_business',
email: 'finance@acme.test',
org_number: '556677-8899',
default_payment_terms: 30,
},
response: {
data: {
id: '0e9c…',
name: 'Acme AB',
customer_type: 'swedish_business',
email: 'finance@acme.test',
org_number: '556677-8899',
vat_number_validated: false,
default_payment_terms: 30,
archived_at: null,
created_at: '2026-05-12T16:00:00Z',
updated_at: '2026-05-12T16:00:00Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'customers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateCustomerSchema },
response: { success: dataEnvelope(CustomerCreated) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'customers.create',
async (request, ctx) => {
const raw = await readV1JsonBody(request, ctx)
if (!raw.ok) return raw.response
const parsed = CreateCustomerSchema.safeParse(raw.body)
if (!parsed.success) {
return v1ValidationError(ctx, parsed.error)
}
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) {
return dryRunPreview(
{
id: null,
name: body.name,
customer_type: body.customer_type,
customer_number: body.customer_number || null,
contact_person: body.contact_person ?? null,
email: body.email ?? null,
phone: body.phone ?? null,
invoice_email_cc_addresses: body.invoice_email_cc_addresses ?? null,
invoice_email_bcc_addresses: body.invoice_email_bcc_addresses ?? null,
address_line1: body.address_line1 ?? null,
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'Sweden',
org_number: body.org_number ?? null,
vat_number: body.vat_number ?? null,
vat_number_validated: false,
personal_number: maskCustomerPersonalNumber(body.personal_number ?? null),
default_payment_terms: defaultPaymentTerms,
notes: body.notes ?? null,
archived_at: null,
created_at: null,
updated_at: null,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Best-effort VIES validation. Resolve BEFORE the insert so the
// resulting row reflects the validation state atomically and the
// API response can't expose stale vat_number_validated.
let vatValidated = false
let vatValidatedAt: string | null = null
if (body.customer_type === 'eu_business' && body.vat_number) {
try {
const vatResult = await validateVatNumber(body.vat_number)
if (vatResult.valid) {
vatValidated = true
vatValidatedAt = new Date().toISOString()
}
} catch (err) {
ctx.log.warn('auto-VIES validation failed on customer create', err as Error)
}
}
const { data, error } = await ctx.supabase
.from('customers')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
name: body.name,
customer_type: body.customer_type,
// Empty string clears the customer number, same as an explicit null
// (matches the internal /api/customers route).
customer_number: body.customer_number || null,
contact_person: body.contact_person ?? null,
email: body.email ?? null,
phone: body.phone ?? null,
invoice_email_cc_addresses: body.invoice_email_cc_addresses ?? null,
invoice_email_bcc_addresses: body.invoice_email_bcc_addresses ?? null,
address_line1: body.address_line1 ?? null,
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'Sweden',
org_number: body.org_number ?? null,
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: defaultPaymentTerms,
notes: body.notes ?? null,
})
.select(CUSTOMER_RESPONSE_COLUMNS)
.single()
if (error) {
if (error.code === '23505') {
// GDPR Art.5(1)(c): do NOT echo body.org_number in the response:
// for customer_type='individual' it IS the personnummer.
// The error code alone tells the caller which field conflicted;
// they already know the value they submitted.
return v1ErrorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', ctx.log, {
requestId: ctx.requestId,
details: { field: 'org_number' },
})
}
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<string, unknown> & { 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
// omits internal scoping fields (user_id, company_id) the Customer type
// requires; we re-inject them on the payload from ctx.
try {
await eventBus.emit({
type: 'customer.created',
payload: {
customer: { ...safeCustomer, user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Customer,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('customer.created emit failed', err as Error)
}
return created(safeCustomer, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)