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:
Mattsson
2026-07-19 00:39:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 46b8e2bfea
commit 9c8e540338
12 changed files with 944 additions and 73 deletions
@@ -52,6 +52,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
const mockIssueCreditNote = vi.fn()
vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
@@ -105,6 +110,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
@@ -376,6 +382,121 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('returns 400 when the body has malformed lines', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: { lines: [{ account_number: 'not-an-account', debit_amount: -5 }] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when custom lines do not balance', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when a row carries both debit and credit', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 50 },
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when custom lines use a 29xx interim account', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '2990', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('books user-edited lines verbatim via the customLines override', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-10' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
const lines = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3041', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string | null
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-10')
// User-edited lines book exactly as reviewed: no accrual schedules.
expect(mockCreateSchedules).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
customer.name,
expect.objectContaining({
customLines: [
expect.objectContaining({ account_number: '1510', debit_amount: 12500 }),
expect.objectContaining({ account_number: '3041', credit_amount: 10000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 2500 }),
],
})
)
})
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
enqueue({ data: company, error: null }) // settings
+97 -27
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/invoices/issue-credit-note'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
@@ -37,9 +38,23 @@ ensureInitialized()
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.mark_sent',
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
// Optional body. Backwards-compat: callers may POST with no body. Read it
// here but validate only AFTER the ownership fetch below, so callers never
// get payload feedback for invoices outside their company.
let rawBody: unknown
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
// Malformed JSON must not silently fall back to generated lines.
return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 })
}
}
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
@@ -61,6 +76,25 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
}
const linesResult = parseCustomIssuanceLines(rawBody)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
log.warn('mark-sent validation failed', { invoiceId: id })
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: linesResult.details },
{ status: 400 },
)
}
return errorResponseFromCode(
linesResult.error === 'unbalanced'
? 'INVOICE_MARK_SENT_LINES_UNBALANCED'
: 'INVOICE_MARK_SENT_LINES_INVALID',
log,
{ requestId, details: linesResult.details },
)
}
const customLines = linesResult.lines
// Assign invoice number now if this draft doesn't have one yet
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
@@ -141,6 +175,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
let journalEntryId: string | null = null
const partialFailures: Array<{ step: string; reason: string }> = []
// Custom lines only apply where mark-sent books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
// mismatch is visible in audit review instead of vanishing silently.
if (
customLines &&
(isCreditNote || !isRealInvoice || !booksInvoicesOnIssue(settings as CompanySettings))
) {
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
invoiceId: id,
lineCount: customLines.length,
})
}
if (isCreditNote && originalInvoice) {
const issueResult = await issueCreditNote({
supabase,
@@ -183,37 +230,60 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
// under kontantmetoden.
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name
)
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
log.info('mark-sent: booking user-edited custom lines', {
invoiceId: id,
userId: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
if (journalEntry) {
journalEntryId = journalEntry.id
// Periodiserade lines: create schedules + catch-up dissolutions now
// that the revenue entry exists. Failures are logged, never fatal:
// the verifikat is committed.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
// the verifikat is committed. Skipped when the user edited the lines:
// the generated 29xx deferral may no longer exist in what was booked,
// and a schedule would then dissolve an interim balance that was
// never credited. User-edited lines book exactly as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
const { error: linkError } = await supabase
@@ -71,6 +71,11 @@ vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read.
@@ -118,6 +123,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockIsConfigured.mockReturnValue(true)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
@@ -606,6 +612,80 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('returns 400 on malformed lines before any email is sent', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines: [{ account_number: 'bad', debit_amount: -1 }] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on unbalanced lines before any email is sent', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 90 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('books user-edited lines verbatim and skips accrual schedules', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-lines' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-20' })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status flip
enqueue({ data: null, error: null }) // journal_entry_id link
const lines = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3041', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
// User-edited lines book exactly as reviewed: no accrual schedules.
expect(mockCreateSchedules).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
undefined,
expect.objectContaining({
customLines: [
expect.objectContaining({ account_number: '1510', debit_amount: 12500 }),
expect.objectContaining({ account_number: '3041', credit_amount: 10000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 2500 }),
],
})
)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
+97 -22
View File
@@ -21,6 +21,7 @@ import {
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -39,11 +40,26 @@ ensureInitialized()
export const POST = withRouteContext(
'invoice.send',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ invoiceId: id })
// Optional body: user-edited issuance lines. Read here; validated after
// the ownership fetch below (no payload feedback for foreign invoices)
// but still long BEFORE the email leaves: after delivery the pipeline
// only degrades to PARTIAL. Same contract as mark-sent.
let rawBody: unknown
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
// Malformed JSON must not silently fall back to generated lines.
return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 })
}
}
// The sandbox must never deliver a real email to a real customer: block
// the entire send pipeline (PDF render + Resend send + status flip).
const blocked = await guardSandbox(supabase, companyId)
@@ -96,6 +112,34 @@ export const POST = withRouteContext(
})
}
const linesResult = parseCustomIssuanceLines(rawBody)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
opLog.warn('send validation failed')
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: linesResult.details },
{ status: 400 },
)
}
return errorResponseFromCode(
linesResult.error === 'unbalanced'
? 'INVOICE_MARK_SENT_LINES_UNBALANCED'
: 'INVOICE_MARK_SENT_LINES_INVALID',
opLog,
{ requestId, details: linesResult.details },
)
}
const customLines = linesResult.lines
// Custom lines only apply where send books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Logged for
// audit visibility instead of vanishing silently.
if (customLines && isCreditNote) {
opLog.warn('send: custom lines ignored (credit notes book via issueCreditNote)', {
lineCount: customLines.length,
})
}
const customer = invoice.customer as Customer
if (!customer.email) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
@@ -361,15 +405,42 @@ export const POST = withRouteContext(
// #967: deferred companies send WITHOUT booking; ekonomi books later via
// POST /api/invoices/[id]/book. The invoice then legitimately sits at
// journal_entry_id = NULL until then, like under kontantmetoden.
if (
customLines &&
!isCreditNote &&
(!isRealInvoice || !booksInvoicesOnIssue(company as CompanySettings))
) {
opLog.warn('send: custom lines ignored (not on accrual book-at-issue path)', {
lineCount: customLines.length,
})
}
if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
)
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
opLog.info('send: booking user-edited custom lines', {
userId: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
undefined,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
)
if (journalEntry) {
createdJournalEntryId = journalEntry.id
await supabase
@@ -380,20 +451,24 @@ export const POST = withRouteContext(
// Periodiserade lines: create their schedules + catch-up
// dissolutions now that the revenue entry exists. Failures degrade
// to PARTIAL: the entry is committed and must not be rolled back.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
// Skipped for user-edited lines: the generated 29xx deferral may
// not exist in what was booked; edited lines book as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
}
} catch (err) {