* 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>
84 lines
2.9 KiB
TypeScript
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 }
|
|
}
|