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>
210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
/**
|
|
* POST /api/v1/companies/{companyId}/journal-entries/batch-create
|
|
*
|
|
* Bulk-create draft journal entries (up to 50 per call). Each item is
|
|
* processed independently: per-item failures don't roll back successes
|
|
* (partial-success semantics, matching /invoices/bulk-create and
|
|
* /suppliers/bulk-create).
|
|
*
|
|
* The endpoint creates DRAFTS only: committing them is a separate per-id
|
|
* call. This keeps batch behaviour symmetric with the single POST and makes
|
|
* the failure modes simpler (no half-committed batches).
|
|
*
|
|
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
|
|
*/
|
|
|
|
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 { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
|
|
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
|
import { createDraftEntry } from '@/lib/bookkeeping/engine'
|
|
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
|
import type { Logger } from '@/lib/logger'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
const BulkRequest = z.object({
|
|
journal_entries: z.array(CreateJournalEntrySchema).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 BulkResponse = z.object({
|
|
results: z.array(BulkResultItem),
|
|
summary: z.object({
|
|
total: z.number().int(),
|
|
succeeded: z.number().int(),
|
|
failed: z.number().int(),
|
|
}),
|
|
})
|
|
|
|
registerEndpoint({
|
|
operation: 'journal-entries.batch-create',
|
|
method: 'POST',
|
|
path: '/api/v1/companies/:companyId/journal-entries/batch-create',
|
|
summary: 'Create up to 50 draft journal entries (partial-success).',
|
|
description:
|
|
'Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Each entry is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns DRAFTS only; commit each separately. Idempotent over the whole batch. Dry-runnable.',
|
|
useWhen:
|
|
'You\'re replaying historical bookkeeping from another system, or batching a set of manual verifikationer from a spreadsheet. Use dry-run first to validate the batch.',
|
|
doNotUseFor:
|
|
'Committing posted entries: use POST /{id}/commit per entry. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.',
|
|
pitfalls: [
|
|
'Idempotency-Key is mandatory and covers the WHOLE batch.',
|
|
'all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.',
|
|
'Each entry must balance independently. Per-item JOURNAL_ENTRY_NOT_BALANCED appears in the results array.',
|
|
],
|
|
example: {
|
|
request: {
|
|
journal_entries: [
|
|
{
|
|
fiscal_period_id: 'a8f1…', entry_date: '2026-05-12', description: 'Bankavgift',
|
|
lines: [
|
|
{ account_number: '6570', debit_amount: 50, credit_amount: 0 },
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 50 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
response: {
|
|
data: {
|
|
results: [{ ok: true, request_index: 0, data: { id: '0e9c…', status: 'draft' } }],
|
|
summary: { total: 1, succeeded: 1, failed: 0 },
|
|
},
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
|
},
|
|
},
|
|
scope: 'bookkeeping:write',
|
|
risk: 'high',
|
|
idempotent: true,
|
|
reversible: true,
|
|
dryRunSupported: true,
|
|
request: { body: BulkRequest },
|
|
response: { success: dataEnvelope(BulkResponse) },
|
|
})
|
|
|
|
interface ResultItem {
|
|
ok: boolean
|
|
request_index: number
|
|
data?: unknown
|
|
error?: { code: string; message: string; details?: unknown }
|
|
}
|
|
|
|
async function createOne(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
index: number,
|
|
input: z.infer<typeof CreateJournalEntrySchema>,
|
|
dryRun: boolean,
|
|
log: Logger,
|
|
): Promise<ResultItem> {
|
|
if (dryRun) {
|
|
return {
|
|
ok: true,
|
|
request_index: index,
|
|
data: {
|
|
preview: {
|
|
status: 'draft' as const,
|
|
voucher_series: input.voucher_series ?? 'A',
|
|
voucher_number: 0,
|
|
fiscal_period_id: input.fiscal_period_id,
|
|
entry_date: input.entry_date,
|
|
description: input.description,
|
|
lines: input.lines,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
try {
|
|
const entry = await createDraftEntry(supabase, companyId, userId, input)
|
|
return {
|
|
ok: true,
|
|
request_index: index,
|
|
data: { id: entry.id, status: entry.status, voucher_series: entry.voucher_series, voucher_number: entry.voucher_number },
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) {
|
|
const e = err as { code?: string; message?: string; details?: unknown }
|
|
return {
|
|
ok: false,
|
|
request_index: index,
|
|
error: {
|
|
code: e.code ?? 'BOOKKEEPING_DATABASE_ERROR',
|
|
message: getUserErrorMessage(e) ?? 'Engine error',
|
|
details: e.details,
|
|
},
|
|
}
|
|
}
|
|
log.error('batch-create: createDraftEntry failed', err as Error, { request_index: index })
|
|
return {
|
|
ok: false,
|
|
request_index: index,
|
|
error: { code: 'BOOKKEEPING_DATABASE_ERROR', message: (err as Error).message ?? 'unknown' },
|
|
}
|
|
}
|
|
}
|
|
|
|
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
|
'journal-entries.batch-create',
|
|
async (request, ctx) => {
|
|
const rawBodyResult = await readV1JsonBody(request, ctx)
|
|
if (!rawBodyResult.ok) return rawBodyResult.response
|
|
const rawBody = rawBodyResult.body
|
|
const parsed = BulkRequest.safeParse(rawBody)
|
|
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
const body = parsed.data
|
|
|
|
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.' },
|
|
})
|
|
}
|
|
|
|
// Ownership pre-check on every distinct fiscal_period_id in the batch.
|
|
// Bulk endpoints are particularly attractive for cross-tenant probing
|
|
// (50 ids per call vs 1) so we batch-verify up front rather than per-
|
|
// item. Any unknown id fails the entire batch with a structured error:
|
|
// partial-success semantics only apply AFTER ownership is established.
|
|
const uniquePeriodIds = Array.from(new Set(body.journal_entries.map((e) => e.fiscal_period_id)))
|
|
for (const periodId of uniquePeriodIds) {
|
|
if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, periodId))) {
|
|
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { resource: 'fiscal_period', field: 'fiscal_period_id', value: periodId },
|
|
})
|
|
}
|
|
}
|
|
|
|
const results: ResultItem[] = []
|
|
for (let i = 0; i < body.journal_entries.length; i++) {
|
|
results.push(await createOne(ctx.supabase, ctx.companyId!, ctx.userId, i, body.journal_entries[i], ctx.dryRun, ctx.log))
|
|
}
|
|
const summary = {
|
|
total: results.length,
|
|
succeeded: results.filter((r) => r.ok).length,
|
|
failed: results.filter((r) => !r.ok).length,
|
|
}
|
|
|
|
ctx.log.info('journal-entries.batch-create completed', { ...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 },
|
|
)
|