fix(invoices): drop UTKAST banner from sent/archived PDFs (#495)

The "UTKAST – inte en giltig faktura" banner in pdf-template.tsx fires
whenever `invoice.status === 'draft'`. All four send-equivalent code
paths render the PDF while the in-memory invoice still reads 'draft',
so the customer's email attachment and the archived underlag are both
stamped as not-a-valid-faktura.

- /api/invoices/[id]/send: status flip happens after email delivery
  (kept that way so a provider failure leaves the row in draft); render
  with `{ ...invoice, status: 'sent' }` instead of mutating order.
- /api/invoices/[id]/mark-sent: DB flip happens before render but the
  in-memory copy is never re-fetched.
- /api/v1/companies/.../invoices/[id]/send: same shape as the internal
  send route; extend the existing `renderableInvoice` override.
- lib/pending-operations/commit.ts (MCP send path): same shape.

The banner condition itself is unchanged — the genuine draft preview
(`/api/invoices/[id]/pdf`) still surfaces the watermark correctly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-15 10:43:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b0890c7c79
commit 1163fd3bee
7 changed files with 98 additions and 4 deletions
@@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
@@ -222,4 +223,26 @@ describe('POST /api/invoices/[id]/mark-sent — PDF archival', () => {
expect.anything()
)
})
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// The in-memory invoice still reads 'draft' after the DB status flip
// (it's never re-fetched). We must override it before render or
// pdf-template.tsx prints the "UTKAST – inte en giltig faktura" banner
// on the archived underlag.
expect(vi.mocked(InvoicePDF)).toHaveBeenCalledTimes(1)
const renderArgs = vi.mocked(InvoicePDF).mock.calls[0][0]
expect(renderArgs.invoice.status).toBe('sent')
expect(renderArgs.invoice.invoice_number).toBe('F-2026010')
})
})
+4 -1
View File
@@ -131,9 +131,12 @@ export async function POST(
originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
}
// The DB status flip already happened above, but the in-memory `invoice`
// is stale and still reads 'draft' — override here so the archived
// underlag isn't stamped "UTKAST – inte en giltig faktura".
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
invoice: { ...(invoice as Invoice), status: 'sent' as const },
customer: invoice.customer as Customer,
items,
company: settings as CompanySettings,
@@ -41,6 +41,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
@@ -364,4 +365,29 @@ describe('POST /api/invoices/[id]/send', () => {
(body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
).toContain('SMTP error')
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// Final render: invoice already has an invoice_number on the fixture, so
// preflight is skipped and InvoicePDF is called exactly once. The status
// passed in must be 'sent' — otherwise pdf-template.tsx renders the
// "UTKAST – inte en giltig faktura" banner on the customer's PDF.
expect(vi.mocked(InvoicePDF)).toHaveBeenCalledTimes(1)
const renderArgs = vi.mocked(InvoicePDF).mock.calls[0][0]
expect(renderArgs.invoice.status).toBe('sent')
expect(renderArgs.invoice.invoice_number).toBe('F-2024001')
})
})
+5 -2
View File
@@ -117,10 +117,13 @@ export const POST = withRouteContext(
}
// Final render with the assigned number — this is the buffer attached to
// the email and later archived as underlag.
// the email and later archived as underlag. Override status to 'sent' on
// the in-memory copy: the DB flip happens after email delivery (line
// ~185), but if we render with the stale 'draft' status the customer
// receives a PDF stamped "UTKAST – inte en giltig faktura".
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
invoice: { ...(invoice as Invoice), status: 'sent' as const },
customer,
items,
company: company as CompanySettings,