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>
154 lines
6.2 KiB
TypeScript
154 lines
6.2 KiB
TypeScript
/**
|
|
* POST /api/v1/companies/{companyId}/voucher-gap-explanations
|
|
*
|
|
* Document a gap in a verifikationsserie per BFL 5 kap 6-7 §§ (the
|
|
* unbroken-löpnummer obligation): supplemented by BFNAR 2013:2 kap 8 §
|
|
* for the systemdokumentation / behandlingshistorik aspect. Voucher
|
|
* numbers are sequential within (fiscal_period_id, voucher_series); any
|
|
* missing number must have a documented explanation. The gap can be a
|
|
* single number (gap_start = gap_end) or a range.
|
|
*
|
|
* Used by:
|
|
* - Migration / import flows that need to claim numbers but can't fill them
|
|
* - Audit response when a number was burned by a failed commit attempt
|
|
* - Operational recovery after manual reconciliation
|
|
*
|
|
* Idempotent (mandatory Idempotency-Key). Insert is small: no dry-run helper.
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import { created } 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 { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
|
|
import { readV1JsonBody } from '@/lib/api/v1/body'
|
|
|
|
const CreateVoucherGapExplanation = z
|
|
.object({
|
|
fiscal_period_id: z.string().uuid(),
|
|
voucher_series: z.string().regex(/^[A-Z]$/, 'voucher_series must be a single uppercase letter'),
|
|
gap_start: z.number().int().positive(),
|
|
gap_end: z.number().int().positive(),
|
|
explanation: z.string().min(1).max(2000),
|
|
})
|
|
.strict()
|
|
.refine((d) => d.gap_end >= d.gap_start, {
|
|
message: 'gap_end must be >= gap_start',
|
|
path: ['gap_end'],
|
|
})
|
|
|
|
const VoucherGapExplanationCreated = z.object({
|
|
id: z.string().uuid(),
|
|
fiscal_period_id: z.string().uuid(),
|
|
voucher_series: z.string(),
|
|
gap_start: z.number().int(),
|
|
gap_end: z.number().int(),
|
|
explanation: z.string(),
|
|
created_at: z.string(),
|
|
})
|
|
|
|
registerEndpoint({
|
|
operation: 'voucher-gap-explanations.create',
|
|
method: 'POST',
|
|
path: '/api/v1/companies/:companyId/voucher-gap-explanations',
|
|
summary: 'Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§).',
|
|
description:
|
|
'Records an explanation for one or more missing voucher numbers in a series. Required when a number is unaccounted for during audit. Statutory basis: BFL 5 kap 6-7 §§ (verifikationsnummer i löpande följd utan luckor); BFNAR 2013:2 kap 8 § governs the systemdokumentation that surfaces the gap. Idempotent. Dry-runnable.',
|
|
useWhen:
|
|
'You\'re responding to a voucher-gap audit finding and need to document the cause. Also used by migration flows that claim numbers without filling them.',
|
|
doNotUseFor:
|
|
'Falsifying a series: every gap MUST have a genuine explanation. The dashboard surfaces these for auditor review.',
|
|
pitfalls: [
|
|
'Idempotency-Key is mandatory.',
|
|
'gap_end must be >= gap_start; a single-number gap has gap_start = gap_end.',
|
|
'voucher_series is a single uppercase letter (A-Z); the same series + period + numeric range must not already exist.',
|
|
],
|
|
example: {
|
|
request: {
|
|
fiscal_period_id: 'a8f1…',
|
|
voucher_series: 'A',
|
|
gap_start: 142,
|
|
gap_end: 145,
|
|
explanation:
|
|
'Migration from previous bookkeeping system on 2026-05-12: series A148-onwards corresponds to the new Accounted numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2.',
|
|
},
|
|
response: {
|
|
data: { id: '0e9c…', voucher_series: 'A', gap_start: 142, gap_end: 145 },
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
|
},
|
|
},
|
|
scope: 'bookkeeping:write',
|
|
risk: 'low',
|
|
idempotent: true,
|
|
reversible: false,
|
|
dryRunSupported: true,
|
|
request: { body: CreateVoucherGapExplanation },
|
|
response: { success: dataEnvelope(VoucherGapExplanationCreated) },
|
|
})
|
|
|
|
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
|
'voucher-gap-explanations.create',
|
|
async (request, ctx) => {
|
|
const rawBodyResult = await readV1JsonBody(request, ctx)
|
|
if (!rawBodyResult.ok) return rawBodyResult.response
|
|
const rawBody = rawBodyResult.body
|
|
const parsed = CreateVoucherGapExplanation.safeParse(rawBody)
|
|
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
const body = parsed.data
|
|
|
|
// Ownership pre-check: the caller-supplied `fiscal_period_id` must belong
|
|
// to ctx.companyId. Otherwise an insert would persist a row with
|
|
// company_id from the URL pointing at a fiscal_period from another
|
|
// company: a broken-link state that confuses every downstream gap-
|
|
// detection query. (No cross-tenant data leak per se, but the row is
|
|
// garbage.)
|
|
const { data: periodCheck } = await ctx.supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('id', body.fiscal_period_id)
|
|
.eq('company_id', ctx.companyId!)
|
|
.maybeSingle()
|
|
if (!periodCheck) {
|
|
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { resource: 'fiscal_period', field: 'fiscal_period_id' },
|
|
})
|
|
}
|
|
|
|
if (ctx.dryRun) {
|
|
return dryRunPreview(
|
|
{
|
|
fiscal_period_id: body.fiscal_period_id,
|
|
voucher_series: body.voucher_series,
|
|
gap_start: body.gap_start,
|
|
gap_end: body.gap_end,
|
|
explanation: body.explanation,
|
|
},
|
|
{ requestId: ctx.requestId, log: ctx.log },
|
|
)
|
|
}
|
|
|
|
const { data, error } = await ctx.supabase
|
|
.from('voucher_gap_explanations')
|
|
.insert({
|
|
company_id: ctx.companyId!,
|
|
user_id: ctx.userId,
|
|
fiscal_period_id: body.fiscal_period_id,
|
|
voucher_series: body.voucher_series,
|
|
gap_start: body.gap_start,
|
|
gap_end: body.gap_end,
|
|
explanation: body.explanation,
|
|
})
|
|
.select('id, fiscal_period_id, voucher_series, gap_start, gap_end, explanation, created_at')
|
|
.single()
|
|
|
|
if (error) {
|
|
ctx.log.error('voucher-gap-explanations insert failed', error)
|
|
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
return created(data, { requestId: ctx.requestId })
|
|
},
|
|
{ requireIdempotencyKey: true },
|
|
)
|