f266c386f3
* 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>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* POST /api/v1/companies/{companyId}/customers/bulk-create
|
|
*
|
|
* Bulk-create up to 50 customers in one call. Each item is validated and
|
|
* inserted independently: per-item failures don't roll back successes.
|
|
* Mirrors the shape of /invoices/bulk-create exactly so agents only need
|
|
* to learn one bulk pattern.
|
|
*
|
|
* Response: `{ results: [{ ok, request_index, data?, error? }], summary }`.
|
|
* Idempotent over the whole batch. Dry-runnable.
|
|
*
|
|
* VIES validation for eu_business customers is best-effort PER ITEM. A VIES
|
|
* timeout does NOT fail the item: it just leaves vat_number_validated=false.
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { ok } from '@/lib/api/v1/response'
|
|
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
|
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
|
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
|
import { 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 } from '@/lib/customers/protect-personal-number'
|
|
import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms'
|
|
import { eventBus } from '@/lib/events'
|
|
import type { Logger } from '@/lib/logger'
|
|
import type { Customer } from '@/types'
|
|
|
|
const BulkCreateRequest = z.object({
|
|
customers: z.array(CreateCustomerSchema).min(1).max(50),
|
|
all_or_nothing: z.boolean().optional().default(false),
|
|
})
|
|
|
|
const BulkResultItem = z.object({
|
|
ok: z.boolean(),
|
|
request_index: z.number().int().nonnegative(),
|
|
data: z.unknown().optional(),
|
|
error: z
|
|
.object({
|
|
code: z.string(),
|
|
message: z.string(),
|
|
details: z.unknown().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const BulkCreateResponse = z.object({
|
|
results: z.array(BulkResultItem),
|
|
summary: z.object({
|
|
total: z.number().int(),
|
|
succeeded: z.number().int(),
|
|
failed: z.number().int(),
|
|
}),
|
|
})
|
|
|
|
// Same projection as the single-create endpoint: keeps response shapes
|
|
// identical so callers can union the two surfaces transparently.
|
|
const CUSTOMER_RESPONSE_COLUMNS =
|
|
'id, name, customer_type, 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, default_payment_terms, notes, archived_at, created_at, updated_at'
|
|
|
|
registerEndpoint({
|
|
operation: 'customers.bulk-create',
|
|
method: 'POST',
|
|
path: '/api/v1/companies/:companyId/customers/bulk-create',
|
|
summary: 'Create up to 50 customers in one call (partial-success).',
|
|
description:
|
|
'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.',
|
|
useWhen:
|
|
'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.',
|
|
doNotUseFor:
|
|
'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.',
|
|
],
|
|
example: {
|
|
request: {
|
|
customers: [
|
|
{ name: 'Acme AB', customer_type: 'swedish_business', org_number: '556677-8899' },
|
|
{ name: 'Foo OY', customer_type: 'eu_business', vat_number: 'FI12345678' },
|
|
],
|
|
},
|
|
response: {
|
|
data: {
|
|
results: [
|
|
{ ok: true, request_index: 0, data: { id: '0e9c…', name: 'Acme AB' } },
|
|
{ ok: true, request_index: 1, data: { id: '4d2a…', name: 'Foo OY' } },
|
|
],
|
|
summary: { total: 2, succeeded: 2, failed: 0 },
|
|
},
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
|
},
|
|
},
|
|
scope: 'customers:write',
|
|
risk: 'low',
|
|
idempotent: true,
|
|
reversible: true,
|
|
dryRunSupported: true,
|
|
request: { body: BulkCreateRequest },
|
|
response: { success: dataEnvelope(BulkCreateResponse) },
|
|
})
|
|
|
|
interface ResultItem {
|
|
ok: boolean
|
|
request_index: number
|
|
data?: unknown
|
|
error?: { code: string; message: string; details?: unknown }
|
|
}
|
|
|
|
async function createOneCustomer(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
index: number,
|
|
input: z.infer<typeof CreateCustomerSchema>,
|
|
dryRun: boolean,
|
|
log: Logger,
|
|
fallbackPaymentTerms: number,
|
|
): Promise<ResultItem> {
|
|
if (dryRun) {
|
|
return {
|
|
ok: true,
|
|
request_index: index,
|
|
data: {
|
|
preview: {
|
|
id: null,
|
|
name: input.name,
|
|
customer_type: input.customer_type,
|
|
contact_person: input.contact_person ?? null,
|
|
email: input.email ?? null,
|
|
phone: input.phone ?? null,
|
|
invoice_email_cc_addresses: input.invoice_email_cc_addresses ?? null,
|
|
invoice_email_bcc_addresses: input.invoice_email_bcc_addresses ?? null,
|
|
address_line1: input.address_line1 ?? null,
|
|
address_line2: input.address_line2 ?? null,
|
|
postal_code: input.postal_code ?? null,
|
|
city: input.city ?? null,
|
|
country: input.country ?? 'Sweden',
|
|
org_number: input.org_number ?? null,
|
|
vat_number: input.vat_number ?? null,
|
|
vat_number_validated: false,
|
|
default_payment_terms: input.default_payment_terms ?? fallbackPaymentTerms,
|
|
notes: input.notes ?? null,
|
|
archived_at: null,
|
|
created_at: null,
|
|
updated_at: null,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Best-effort VIES validation. Resolve BEFORE the insert so the row
|
|
// reflects validation state atomically.
|
|
let vatValidated = false
|
|
let vatValidatedAt: string | null = null
|
|
if (input.customer_type === 'eu_business' && input.vat_number) {
|
|
try {
|
|
const vatResult = await validateVatNumber(input.vat_number)
|
|
if (vatResult.valid) {
|
|
vatValidated = true
|
|
vatValidatedAt = new Date().toISOString()
|
|
}
|
|
} catch (err) {
|
|
log.warn('bulk-create: VIES validation failed for item', err as Error, {
|
|
request_index: index,
|
|
})
|
|
}
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('customers')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: input.name,
|
|
customer_type: input.customer_type,
|
|
contact_person: input.contact_person ?? null,
|
|
email: input.email ?? null,
|
|
phone: input.phone ?? null,
|
|
invoice_email_cc_addresses: input.invoice_email_cc_addresses ?? null,
|
|
invoice_email_bcc_addresses: input.invoice_email_bcc_addresses ?? null,
|
|
address_line1: input.address_line1 ?? null,
|
|
address_line2: input.address_line2 ?? null,
|
|
postal_code: input.postal_code ?? null,
|
|
city: input.city ?? null,
|
|
country: input.country ?? 'Sweden',
|
|
org_number: input.org_number ?? null,
|
|
vat_number: input.vat_number ?? null,
|
|
vat_number_validated: vatValidated,
|
|
vat_number_validated_at: vatValidatedAt,
|
|
// Stored as ciphertext (customers_personal_number_check). The response
|
|
// projection deliberately excludes it; nothing here leaks it.
|
|
personal_number: encryptCustomerPersonalNumber(input.personal_number),
|
|
default_payment_terms: input.default_payment_terms ?? fallbackPaymentTerms,
|
|
notes: input.notes ?? null,
|
|
})
|
|
.select(CUSTOMER_RESPONSE_COLUMNS)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === '23505') {
|
|
// GDPR Art.5(1)(c): do NOT echo input.org_number; for sole traders
|
|
// it IS the personnummer. The error code + field is enough: the
|
|
// caller knows the value they submitted.
|
|
return {
|
|
ok: false,
|
|
request_index: index,
|
|
error: {
|
|
code: 'CUSTOMER_DUPLICATE_ORG_NUMBER',
|
|
message: 'A customer with this org_number already exists in this company.',
|
|
details: { field: 'org_number' },
|
|
},
|
|
}
|
|
}
|
|
log.error('bulk-create: customer insert failed', error, {
|
|
request_index: index,
|
|
companyId,
|
|
pgCode: error.code,
|
|
})
|
|
return {
|
|
ok: false,
|
|
request_index: index,
|
|
error: {
|
|
code: 'CUSTOMER_CREATE_FAILED',
|
|
message: 'Customer insert failed.',
|
|
details: { pg_code: error.code },
|
|
},
|
|
}
|
|
}
|
|
|
|
// Emit customer.created per success. Same cast pattern as the single
|
|
// POST: projection omits internal scoping fields we re-inject here.
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'customer.created',
|
|
payload: {
|
|
customer: {
|
|
...(data as Record<string, unknown>),
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
} as unknown as Customer,
|
|
companyId,
|
|
userId,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
log.warn('bulk-create: customer.created emit failed', err as Error, {
|
|
request_index: index,
|
|
})
|
|
}
|
|
|
|
return { ok: true, request_index: index, data }
|
|
}
|
|
|
|
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
|
'customers.bulk-create',
|
|
async (request, ctx) => {
|
|
if (!z.string().uuid().safeParse(ctx.companyId).success) {
|
|
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { field: 'companyId', message: 'companyId must be a UUID.' },
|
|
})
|
|
}
|
|
|
|
const rawBodyResult = await readV1JsonBody(request, ctx)
|
|
if (!rawBodyResult.ok) return rawBodyResult.response
|
|
const rawBody = rawBodyResult.body
|
|
|
|
const parsed = BulkCreateRequest.safeParse(rawBody)
|
|
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
const body = parsed.data
|
|
|
|
// Reject all_or_nothing: true loudly. Same contract as invoices/bulk-create.
|
|
if (body.all_or_nothing) {
|
|
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: {
|
|
field: 'all_or_nothing',
|
|
message:
|
|
'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.',
|
|
},
|
|
})
|
|
}
|
|
|
|
// Items without explicit payment terms follow the company's own default,
|
|
// not a hardcoded 30. Resolved once for the whole batch.
|
|
const fallbackPaymentTerms = await resolveDefaultPaymentTerms(
|
|
ctx.supabase,
|
|
ctx.companyId!,
|
|
undefined,
|
|
)
|
|
|
|
// Sequential processing: matches /invoices/bulk-create. VIES has its own
|
|
// upstream throughput limits; running a batch of 50 in parallel can trip
|
|
// them. The 50-item cap keeps the worst-case latency bounded.
|
|
const results: ResultItem[] = []
|
|
for (let i = 0; i < body.customers.length; i++) {
|
|
const item = await createOneCustomer(
|
|
ctx.supabase,
|
|
ctx.companyId!,
|
|
ctx.userId,
|
|
i,
|
|
body.customers[i],
|
|
ctx.dryRun,
|
|
ctx.log,
|
|
fallbackPaymentTerms,
|
|
)
|
|
results.push(item)
|
|
}
|
|
|
|
const summary = {
|
|
total: results.length,
|
|
succeeded: results.filter((r) => r.ok).length,
|
|
failed: results.filter((r) => !r.ok).length,
|
|
}
|
|
|
|
ctx.log.info('customers.bulk-create completed', {
|
|
companyId: ctx.companyId,
|
|
userId: ctx.userId,
|
|
...summary,
|
|
dryRun: ctx.dryRun,
|
|
})
|
|
|
|
if (ctx.dryRun) {
|
|
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
|
|
}
|
|
return ok({ results, summary }, { requestId: ctx.requestId })
|
|
},
|
|
{ requireIdempotencyKey: true },
|
|
)
|