Files
accounted/app/api/v1/companies/[companyId]/invoices/bulk-create/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

494 lines
18 KiB
TypeScript

/**
* POST /api/v1/companies/{companyId}/invoices/bulk-create
*
* Bulk create draft invoices in one call. Each invoice in the request array
* is validated and inserted independently. The response is partial-success
* by default: items that fail don't roll back items that succeeded.
*
* Request:
* {
* invoices: CreateInvoiceSchema[], // 1..50 items
* all_or_nothing?: boolean // default false. Passing true returns
* // 501 NOT_IMPLEMENTED: the flag is
* // reserved for a future DB-side RPC.
* }
*
* Response (200):
* {
* results: [
* { ok: true, request_index: 0, data: { id, invoice_number, total, ... } },
* { ok: false, request_index: 1, error: { code, message, details? } },
* ...
* ],
* summary: { total: N, succeeded: X, failed: Y }
* }
*
* Idempotent (mandatory Idempotency-Key applies to the whole batch: replays
* return the cached full result, not per-item retries). Dry-runnable.
*
* Limits:
* - 50 invoices per request. Larger imports should be split.
* - No transactional guarantee between items today; the all_or_nothing
* flag is reserved for a future RPC implementation.
*/
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 { CreateInvoiceSchema } from '@/lib/api/schemas'
import { getPermittedVatRates, getVatRules } from '@/lib/invoices/vat-rules'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
import type { Invoice, InvoiceDocumentType } from '@/types'
const BulkCreateRequest = z.object({
invoices: z.array(CreateInvoiceSchema).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(),
}),
})
const INVOICE_BULK_RESPONSE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, status, currency, subtotal, vat_amount, total, document_type, created_at'
registerEndpoint({
operation: 'invoices.bulk-create',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices/bulk-create',
summary: 'Create up to 50 draft invoices in one call (partial-success).',
description:
'Bulk-creation endpoint. Each invoice in the request array is validated and inserted independently. By default, individual failures do not roll back successes: the response carries a per-item results array with ok/error markers and a summary. Idempotent (the whole batch is keyed by the single Idempotency-Key). Dry-runnable.',
useWhen:
'You\'re importing a batch of invoices from another system, or producing many invoices programmatically (e.g. monthly subscription billing). Use dry-run first to validate the whole batch before committing.',
doNotUseFor:
'Sending the same invoice to multiple customers: POST /invoices once per customer. Long-running imports of > 50 invoices: split into pages. Transactional all-or-nothing imports: not yet supported (passing all_or_nothing: true returns 501 NOT_IMPLEMENTED; the flag is reserved for a future RPC).',
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).',
'Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.',
'Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item\'s SEK columns null but does NOT fail the item.',
],
example: {
request: {
invoices: [
{
customer_id: 'a8f1…',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [{ description: 'A', quantity: 1, unit: 'st', unit_price: 1000 }],
},
],
},
response: {
data: {
results: [
{
ok: true,
request_index: 0,
data: {
id: '0e9c…',
invoice_number: null,
status: 'draft',
total: 1250,
},
},
],
summary: { total: 1, succeeded: 1, failed: 0 },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'medium',
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 }
}
/**
* Create one invoice (the bulk-create item path). Mirrors the inline logic
* in POST /invoices/route.ts. Returns a ResultItem instead of an HTTP
* response so the caller can aggregate.
*
* TODO: extract POST /invoices's create logic into a shared lib function
* so this and the per-call POST share one implementation. Tracked as
* cross-route cleanup.
*/
async function createOneInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
index: number,
input: z.infer<typeof CreateInvoiceSchema>,
dryRun: boolean,
log: Logger,
): Promise<ResultItem> {
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
// Customer fetch (scoped to company). We use the DB-returned `customer.id`
// (not `input.customer_id`) downstream as defense in depth: the .eq()
// pair already enforces company scoping, but echoing the trusted value
// from the query makes the guarantee explicit at the call site and
// immune to refactoring drift.
const { data: customer } = await supabase
.from('customers')
.select('id, customer_type, vat_number_validated')
.eq('company_id', companyId)
.eq('id', input.customer_id)
.maybeSingle()
if (!customer) {
return {
ok: false,
request_index: index,
error: { code: 'INVOICE_CUSTOMER_NOT_FOUND', message: 'Customer not found in this company.' },
}
}
const verifiedCustomerId = (customer as { id: string }).id
const vatRules = getVatRules(
customer.customer_type as Parameters<typeof getVatRules>[0],
customer.vat_number_validated,
)
// Gate on the PERMITTED set, not the picker default, exactly like
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
// (hotel/restaurang 12%, persontransport and event admission 6%,
// fastighetstjänst and korttidsuthyrning 25%) carry Swedish VAT even to a
// foreign business customer. The default is still 0% (vatRules.rate is the
// fallback below), so a Swedish rate only lands here when sent explicitly.
const permittedRates = getPermittedVatRates(
customer.customer_type as Parameters<typeof getPermittedVatRates>[0],
customer.vat_number_validated,
)
const allowedRates = new Set(permittedRates.map((r) => r.rate))
// VAT registration gate, mirroring buildInvoiceWriteData (issue #1719): a
// non-momsregistrerad company books no output VAT, so every line is forced
// to 0% (momsfri) server-side no matter what the batch payload carries,
// explicitly or via the customer-default fallback below. 0% is a permitted
// rate for every customer type, so the allowedRates gate still passes.
const { data: vatSettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId)
.maybeSingle()
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered && documentType !== 'delivery_note') {
for (const item of input.items) item.vat_rate = 0
}
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of input.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return {
ok: false,
request_index: index,
error: {
code: 'INVOICE_CREATE_VAT_RULE_VIOLATION',
message: 'A line item carries a VAT rate not allowed for the customer type.',
details: {
attempted_rate: itemRate,
allowed_rates: Array.from(allowedRates),
customer_type: customer.customer_type,
},
},
}
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
const uniqueRates = new Set(input.items.map((it) => it.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
const headerVatRate = documentType === 'delivery_note'
? 0
: isMixedRate
? null
: (uniqueRates.values().next().value ?? vatRules.rate)
// Currency conversion (best-effort per item).
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (input.currency !== 'SEK') {
try {
const rate = await fetchExchangeRate(input.currency)
if (rate) {
exchangeRate = rate.rate
exchangeRateDate = rate.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
} catch (err) {
log.warn('bulk-create: exchange-rate fetch failed for item', err as Error, {
request_index: index,
currency: input.currency,
})
}
}
const itemRows = input.items.map((item, sortOrder) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note'
? 0
: Math.round((lineTotal * itemRate) / 100 * 100) / 100
return {
sort_order: sortOrder,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
// Dimensions PR7: per-item bag, merged over the invoice's
// default_dimensions on the revenue line when the JE posts at :send.
dimensions: item.dimensions ?? {},
}
})
if (dryRun) {
return {
ok: true,
request_index: index,
data: {
preview: {
invoice_number: null,
customer_id: verifiedCustomerId,
invoice_date: input.invoice_date,
due_date: input.due_date,
status: 'draft' as const,
currency: input.currency,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
vat_amount: vatAmount,
total,
document_type: documentType,
items: itemRows,
},
},
}
}
// Commit path. Insert invoice header.
const { data: invoice, error: invoiceErr } = await supabase
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: verifiedCustomerId,
invoice_number: null,
invoice_date: input.invoice_date,
due_date: input.due_date,
delivery_date: input.delivery_date ?? null,
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: documentType === 'delivery_note' ? null : totalSek,
remaining_amount: documentType === 'invoice' ? total : 0,
// Header VAT fields mirror buildInvoiceWriteData: a not-VAT-registered
// company stamps the sale as momsfri (treatment 'exempt', no ruta, no
// reverse-charge notation); every line rate is already zeroed above.
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: headerVatRate,
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
your_reference: input.your_reference,
our_reference: input.our_reference,
notes: input.notes,
document_type: documentType,
// Dimensions PR7: invoice-level bag; the :send JE generator applies it
// to every line (items[].dimensions win per key).
default_dimensions: input.default_dimensions ?? {},
})
.select(INVOICE_BULK_RESPONSE_COLUMNS)
.single()
if (invoiceErr) {
log.error('bulk-create: invoice insert failed', invoiceErr, {
request_index: index,
companyId,
pgCode: invoiceErr.code,
})
return {
ok: false,
request_index: index,
error: {
code: 'INVOICE_CREATE_INSERT_FAILED',
message: 'Invoice insert failed.',
details: { pg_code: invoiceErr.code },
},
}
}
const invoiceId = (invoice as { id: string }).id
const itemsToInsert = itemRows.map((r) => ({ ...r, invoice_id: invoiceId }))
const { error: itemsErr } = await supabase.from('invoice_items').insert(itemsToInsert)
if (itemsErr) {
// Roll back this invoice; other batch items are unaffected.
const { error: rbErr } = await supabase
.from('invoices')
.delete()
.eq('id', invoiceId)
.eq('company_id', companyId)
if (rbErr) {
log.error(
'bulk-create: items insert failed AND rollback delete failed, orphaned header',
rbErr,
{ request_index: index, invoiceId, companyId, originalPgCode: itemsErr.code },
)
} else {
log.error('bulk-create: items insert failed; rolled back invoice', itemsErr, {
request_index: index,
invoiceId,
companyId,
})
}
return {
ok: false,
request_index: index,
error: {
code: 'INVOICE_CREATE_ITEMS_FAILED',
message: 'Invoice items insert failed; the invoice was rolled back.',
details: { pg_code: itemsErr.code },
},
}
}
// Emit invoice.created per successful item (matches POST /invoices).
if (documentType === 'invoice') {
try {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: invoice as unknown as Invoice, companyId, userId },
})
} catch (err) {
log.warn('bulk-create: invoice.created emit failed', err as Error, {
request_index: index,
invoiceId,
})
}
}
return { ok: true, request_index: index, data: invoice }
}
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'invoices.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. The schema accepts it for forward
// compatibility, but a caller asking for atomic semantics must not get
// partial-success behaviour silently: that would let an automation
// depend on a guarantee that doesn't exist. The flag will be honored
// once a DB-side RPC ships; until then it's 501.
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.',
},
})
}
// Run items sequentially. Parallel would be faster but the database-side
// sequence allocation for delivery notes and the auditability of the
// log stream are easier to reason about sequentially. 50-item cap keeps
// the worst-case latency bounded.
const results: ResultItem[] = []
for (let i = 0; i < body.invoices.length; i++) {
const item = await createOneInvoice(
ctx.supabase,
ctx.companyId!,
ctx.userId,
i,
body.invoices[i],
ctx.dryRun,
ctx.log,
)
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('invoices.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 },
)