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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
46b8e2bfea
commit
9c8e540338
@@ -668,6 +668,22 @@ export const MarkInvoicePaidSchema = z.object({
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const MarkInvoiceSentSchema = z.object({
|
||||
// Optional user-edited issuance lines ("Markera som skickad och bokför").
|
||||
// When present they replace the generated invoice entry verbatim: the route
|
||||
// validates balance and books exactly these lines, and accrual schedules
|
||||
// are NOT created (what the user reviewed is what books). Only honored on
|
||||
// the accrual book-at-issue path; ignored for credit notes, cash-method
|
||||
// and deferred-booking companies, which don't book at mark-sent.
|
||||
lines: z.array(z.object({
|
||||
account_number: accountNumber,
|
||||
debit_amount: nonNegativeAmount.default(0),
|
||||
credit_amount: nonNegativeAmount.default(0),
|
||||
line_description: z.string().optional(),
|
||||
dimensions: DimensionsBagSchema.optional(),
|
||||
})).min(2).optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Customer schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -334,8 +334,15 @@ export async function createInvoiceJournalEntry(
|
||||
* verifikation should read "Självfaktura <external number>" rather than
|
||||
* "Kundfaktura <our number>", and the number tag must be the counterparty's
|
||||
* external number because the row has no own `invoice_number`.
|
||||
*
|
||||
* customLines: user-edited rows from the send dialog. Booked verbatim
|
||||
* (caller validates balance); line generation is skipped entirely.
|
||||
*/
|
||||
options?: { descriptionPrefix?: string; numberOverride?: string | null }
|
||||
options?: {
|
||||
descriptionPrefix?: string
|
||||
numberOverride?: string | null
|
||||
customLines?: CreateJournalEntryLineInput[]
|
||||
}
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -343,6 +350,22 @@ export async function createInvoiceJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
if (options?.customLines && options.customLines.length > 0) {
|
||||
return createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: invoice.invoice_date,
|
||||
description: buildInvoiceDescription(
|
||||
options?.descriptionPrefix ?? 'Kundfaktura',
|
||||
options?.numberOverride ?? invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.id,
|
||||
),
|
||||
source_type: 'invoice_created',
|
||||
source_id: invoice.id,
|
||||
lines: options.customLines,
|
||||
})
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
const tag = options?.numberOverride ?? invoiceTag(invoice)
|
||||
|
||||
@@ -796,6 +796,16 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Fakturan ändrades av en annan begäran. Ladda om och försök igen.',
|
||||
message_en: 'The invoice was changed by another request. Reload and retry.',
|
||||
},
|
||||
INVOICE_MARK_SENT_LINES_UNBALANCED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).',
|
||||
message_en: 'Custom journal lines do not balance.',
|
||||
},
|
||||
INVOICE_MARK_SENT_LINES_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationsraderna kan inte användas: en rad har både debet och kredit, eller använder ett interimskonto (29xx). Använd periodisering på fakturaraden istället.',
|
||||
message_en: 'Custom journal lines are invalid: a row carries both debit and credit, or uses a 29xx interim account. Use line-level periodisering instead.',
|
||||
},
|
||||
INVOICE_MARK_SENT_BOOK_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte bokföras och ligger kvar som utkast.',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseCustomIssuanceLines } from '../issuance-custom-lines'
|
||||
|
||||
describe('parseCustomIssuanceLines', () => {
|
||||
const balanced = [
|
||||
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
]
|
||||
|
||||
it('passes through a missing body as no lines', () => {
|
||||
expect(parseCustomIssuanceLines(null)).toEqual({ ok: true, lines: undefined })
|
||||
expect(parseCustomIssuanceLines(undefined)).toEqual({ ok: true, lines: undefined })
|
||||
})
|
||||
|
||||
it('accepts a body without lines', () => {
|
||||
expect(parseCustomIssuanceLines({})).toEqual({ ok: true, lines: undefined })
|
||||
})
|
||||
|
||||
it('accepts balanced lines', () => {
|
||||
const result = parseCustomIssuanceLines({ lines: balanced })
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.lines).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects malformed bodies via the schema', () => {
|
||||
const result = parseCustomIssuanceLines({ lines: [{ account_number: 'x', debit_amount: -1 }] })
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_body' })
|
||||
})
|
||||
|
||||
it('rejects fewer than two lines', () => {
|
||||
const result = parseCustomIssuanceLines({ lines: [balanced[0]] })
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_body' })
|
||||
})
|
||||
|
||||
it('rejects unbalanced lines', () => {
|
||||
const result = parseCustomIssuanceLines({
|
||||
lines: [balanced[0], { account_number: '3001', debit_amount: 0, credit_amount: 9999 }],
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
|
||||
})
|
||||
|
||||
it('rejects zero-debit entries', () => {
|
||||
const result = parseCustomIssuanceLines({
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 0 },
|
||||
],
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
|
||||
})
|
||||
|
||||
it('rejects sub-öre payloads whose raw sums balance but rounded sums do not', () => {
|
||||
const result = parseCustomIssuanceLines({
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 0.004, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0.004, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 0.008 },
|
||||
],
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
|
||||
})
|
||||
|
||||
it('rejects a row carrying both debit and credit', () => {
|
||||
const result = parseCustomIssuanceLines({
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 100, credit_amount: 50 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
|
||||
],
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'invalid_lines',
|
||||
details: { reason: 'both_sides', index: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects 29xx interim accounts', () => {
|
||||
const result = parseCustomIssuanceLines({
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
|
||||
{ account_number: '2990', debit_amount: 0, credit_amount: 12500 },
|
||||
],
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'invalid_lines',
|
||||
details: { reason: 'accrual_interim_account', account: '2990' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user