Files
accounted/lib/invoices/issuance-custom-lines.ts
T
MattssonandClaude Fable 5 9c8e540338 fix(invoices): repair send dialog fiscal-period query + editable issu… (#1066)
* fix(invoices): repair send dialog fiscal-period query + editable issuance lines

The send/mark-sent dialog queried fiscal_periods with start_date/end_date
instead of period_start/period_end; the query always 400ed, and since PR
#1023 made that fatal the dialog closed instantly, blocking mark-as-sent
and email send for everyone.

Also lets accrual companies edit the proposed journal lines before booking
(both send and mark-sent), mirroring the mark-paid editor: untouched
proposals still book via the server generator; edited lines book verbatim
with balance validated at three layers. Credit notes and periodiserade
invoices keep the read-only preview. The dialog now also respects
defer_invoice_booking (#967).

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

* fix(invoices): harden custom issuance-line validation per review findings

Extract the custom-line parse + balance check into a shared validator so
the send and mark-sent routes cannot drift. Reject rows carrying both
debit and credit, and 29xx interim accounts (custom lines skip accrual
schedule creation, so a 29xx balance would never be dissolved). Validate
the payload only after the invoice ownership fetch, and emit structured
log events when user-edited lines are booked or deliberately ignored, so
manual overrides are visible in audit review.

Account existence needs no route-level check: the engine already resolves
every account against the company chart and throws AccountsNotInChartError.

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

* fix(invoices): address CodeRabbit findings on issuance line editing

Reject malformed JSON bodies with 400 instead of silently booking
generated lines; restrict line editing to SEK invoices (custom lines
cannot carry FX metadata); round each line before the client balance
check to match the server; stop claiming a voucher was created in the
mark-sent toast for deferred-booking companies; add programmatic labels
to the editor inputs and remove-row buttons.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:39:56 +02:00

84 lines
2.9 KiB
TypeScript

import { MarkInvoiceSentSchema } from '@/lib/api/schemas'
import { roundOre } from '@/lib/money'
/**
* Shared parse + validation for user-edited issuance lines accepted by
* POST /api/invoices/[id]/mark-sent and POST /api/invoices/[id]/send.
* One implementation so the two routes cannot drift (compliance V2.2).
*/
export interface CustomIssuanceLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
dimensions?: Record<string, string>
}
export type CustomIssuanceLinesResult =
| { ok: true; lines: CustomIssuanceLine[] | undefined }
| { ok: false; error: 'invalid_body'; details: unknown }
| {
ok: false
error: 'unbalanced'
details: { totalDebit: number; totalCredit: number }
}
| {
ok: false
error: 'invalid_lines'
details: { reason: 'both_sides'; index: number } | { reason: 'accrual_interim_account'; account: string }
}
/**
* Parse an already-JSON-decoded request body and validate any custom lines.
*
* Rules beyond the Zod schema:
* - A row may not carry both a debit and a credit amount (the UI enforces
* exclusion; API callers get a clean 400 instead of a nonstandard entry).
* - 29xx interim accounts (förutbetalda intäkter) are rejected: custom lines
* skip accrual schedule creation, so a 29xx balance booked here would never
* be dissolved and would sit invisible to the periodisering monitoring.
* - Per-line öre-rounded totals must balance and be positive: the engine
* rounds each line before insert, so raw sums that balance can still book
* unbalanced otherwise.
*
* Account existence is NOT checked here: the engine resolves every account
* against the company's chart of accounts and throws AccountsNotInChartError.
*/
export function parseCustomIssuanceLines(rawBody: unknown): CustomIssuanceLinesResult {
if (rawBody == null) return { ok: true, lines: undefined }
const parsed = MarkInvoiceSentSchema.safeParse(rawBody)
if (!parsed.success) {
return { ok: false, error: 'invalid_body', details: parsed.error.flatten() }
}
const lines = parsed.data.lines
if (!lines) return { ok: true, lines: undefined }
for (const [index, line] of lines.entries()) {
if (line.debit_amount > 0 && line.credit_amount > 0) {
return {
ok: false,
error: 'invalid_lines',
details: { reason: 'both_sides', index },
}
}
if (/^29\d{2}$/.test(line.account_number)) {
return {
ok: false,
error: 'invalid_lines',
details: { reason: 'accrual_interim_account', account: line.account_number },
}
}
}
const totalDebit = lines.reduce((s, l) => s + roundOre(l.debit_amount), 0)
const totalCredit = lines.reduce((s, l) => s + roundOre(l.credit_amount), 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return { ok: false, error: 'unbalanced', details: { totalDebit, totalCredit } }
}
return { ok: true, lines }
}