diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 820d0954..3dfee258 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -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') + }) }) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 8ceb8045..ff95c8eb 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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, diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index ada8c6f8..05c4067e 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -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') + }) }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index d1297964..fd46a782 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index c2d7f4c5..85fe35c7 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 31461fb5..5f5cacea 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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 diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 888769da..ec9c5a42 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -589,9 +589,12 @@ async function commitSendInvoice( if (orig) originalInvoiceNumber = orig.invoice_number } + // Override `status` to 'sent' on the in-memory copy. The DB flip happens + // after email delivery (line ~625); rendering with the stale 'draft' status + // would stamp the customer's PDF with "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,