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:
co-authored by
Claude Opus 4.7
parent
b0890c7c79
commit
1163fd3bee
@@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({
|
|||||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||||
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
||||||
}))
|
}))
|
||||||
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||||
|
|
||||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||||
@@ -222,4 +223,26 @@ describe('POST /api/invoices/[id]/mark-sent — PDF archival', () => {
|
|||||||
expect.anything()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -131,9 +131,12 @@ export async function POST(
|
|||||||
originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
|
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(
|
const pdfBuffer = await renderToBuffer(
|
||||||
InvoicePDF({
|
InvoicePDF({
|
||||||
invoice: invoice as Invoice,
|
invoice: { ...(invoice as Invoice), status: 'sent' as const },
|
||||||
customer: invoice.customer as Customer,
|
customer: invoice.customer as Customer,
|
||||||
items,
|
items,
|
||||||
company: settings as CompanySettings,
|
company: settings as CompanySettings,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ vi.mock('@react-pdf/renderer', () => ({
|
|||||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||||
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
||||||
}))
|
}))
|
||||||
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||||
|
|
||||||
const mockSendEmail = vi.fn()
|
const mockSendEmail = vi.fn()
|
||||||
const mockIsConfigured = 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,
|
(body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
|
||||||
).toContain('SMTP error')
|
).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,10 +117,13 @@ export const POST = withRouteContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Final render with the assigned number — this is the buffer attached to
|
// 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(
|
const pdfBuffer = await renderToBuffer(
|
||||||
InvoicePDF({
|
InvoicePDF({
|
||||||
invoice: invoice as Invoice,
|
invoice: { ...(invoice as Invoice), status: 'sent' as const },
|
||||||
customer,
|
customer,
|
||||||
items,
|
items,
|
||||||
company: company as CompanySettings,
|
company: company as CompanySettings,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ vi.mock('@/lib/email/invoice-templates', () => ({
|
|||||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||||
InvoicePDF: vi.fn().mockReturnValue({}),
|
InvoicePDF: vi.fn().mockReturnValue({}),
|
||||||
}))
|
}))
|
||||||
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||||
|
|
||||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||||
import { POST as sendInvoice } from '../route'
|
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 () => {
|
it('rejects keys without invoices:write scope', async () => {
|
||||||
mockValidate.mockResolvedValue({
|
mockValidate.mockResolvedValue({
|
||||||
userId: USER_ID,
|
userId: USER_ID,
|
||||||
|
|||||||
@@ -340,9 +340,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
}
|
}
|
||||||
const finalInvoiceNumber =
|
const finalInvoiceNumber =
|
||||||
(numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number
|
(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 = {
|
const renderableInvoice: Invoice = {
|
||||||
...(typed as Invoice),
|
...(typed as Invoice),
|
||||||
invoice_number: finalInvoiceNumber,
|
invoice_number: finalInvoiceNumber,
|
||||||
|
status: 'sent',
|
||||||
}
|
}
|
||||||
|
|
||||||
let pdfBuffer: Buffer
|
let pdfBuffer: Buffer
|
||||||
|
|||||||
@@ -589,9 +589,12 @@ async function commitSendInvoice(
|
|||||||
if (orig) originalInvoiceNumber = orig.invoice_number
|
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(
|
const pdfBuffer = await renderToBuffer(
|
||||||
InvoicePDF({
|
InvoicePDF({
|
||||||
invoice: invoice as Invoice,
|
invoice: { ...(invoice as Invoice), status: 'sent' as const },
|
||||||
customer,
|
customer,
|
||||||
items,
|
items,
|
||||||
company: company as CompanySettings,
|
company: company as CompanySettings,
|
||||||
|
|||||||
Reference in New Issue
Block a user