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
@@ -72,6 +72,7 @@ vi.mock('@/lib/email/invoice-templates', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as sendInvoice } from '../route'
@@ -370,6 +371,36 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: DRAFT_INVOICE, error: null }, // pre-flight fetch
{ data: { invoice_number: '2026-0043' }, error: null }, // re-read after allocation
],
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
// DRAFT_INVOICE has invoice_number: null, so isFreshAllocation is true and
// a preflight render runs first with the F-PREVIEW placeholder. The final
// render is the second call — its invoice must carry status: 'sent' and
// the freshly-assigned invoice_number, otherwise the customer's PDF is
// stamped "UTKAST – inte en giltig faktura".
const calls = vi.mocked(InvoicePDF).mock.calls
expect(calls.length).toBeGreaterThanOrEqual(2)
const finalRenderArgs = calls[calls.length - 1][0]
expect(finalRenderArgs.invoice.status).toBe('sent')
expect(finalRenderArgs.invoice.invoice_number).toBe('2026-0043')
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
@@ -340,9 +340,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const finalInvoiceNumber =
(numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number
// Also override `status` to 'sent' on the in-memory copy. The actual DB
// flip happens at step 9a (after email delivery), but if we render with
// the stale 'draft' status the customer receives a PDF stamped
// "UTKAST – inte en giltig faktura".
const renderableInvoice: Invoice = {
...(typed as Invoice),
invoice_number: finalInvoiceNumber,
status: 'sent',
}
let pdfBuffer: Buffer