feat(invoice): add PDF archival for sent invoices and implement tests (#373)

* feat(invoice): add PDF archival for sent invoices and implement tests

* fix(invoice): add company_id filter when fetching original invoice
This commit is contained in:
Mattsson
2026-04-28 13:03:53 +02:00
committed by GitHub
parent 566b9696f8
commit d1267c37af
4 changed files with 288 additions and 3 deletions
+9
View File
@@ -954,6 +954,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<DialogTitle>Ta bort fakturautkast</DialogTitle>
<DialogDescription>
Är du säker på att du vill ta bort {invoice.invoice_number ? `utkast ${invoice.invoice_number}` : 'utkastet'}? Detta kan inte ångras.
{invoice.invoice_number ? (
<span className="mt-2 block text-destructive">
Löpnummer {invoice.invoice_number} är redan reserverat och kommer att bli ett permanent hopp i fakturaserien.
</span>
) : (
<span className="mt-2 block text-muted-foreground">
Inget löpnummer har tilldelats — fakturaserien påverkas inte.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
makeCompanySettings,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
Document: vi.fn(),
Page: vi.fn(),
Text: vi.fn(),
View: vi.fn(),
StyleSheet: { create: (s: unknown) => s },
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) =>
mockCreateInvoiceJournalEntry(...args),
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/mark-sent — PDF archival', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({
accounting_method: 'accrual',
entity_type: 'enskild_firma',
})
const invoice = makeInvoice({
id: 'inv-1',
invoice_number: 'F-2026010',
status: 'draft',
customer,
items: [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Consulting',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
],
})
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
})
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
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, 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-7')
expect(mockRenderToBuffer).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({
name: 'faktura-F-2026010.pdf',
type: 'application/pdf',
}),
expect.objectContaining({
upload_source: 'system',
journal_entry_id: 'je-7',
})
)
})
it('archives the PDF even when journal entry creation fails (non-blocking)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
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).toBeNull()
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'faktura-F-2026010.pdf' }),
expect.objectContaining({
upload_source: 'system',
journal_entry_id: undefined,
})
)
})
it('still returns 200 when PDF archival itself fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
enqueue({ data: null, error: null })
mockUploadDocument.mockRejectedValue(new Error('Storage offline'))
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
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)
})
it('skips PDF archival for proforma invoices', async () => {
const proforma = makeInvoice({
id: 'inv-2',
invoice_number: 'PF-2026005',
status: 'draft',
document_type: 'proforma',
customer,
items: invoice.items,
})
enqueue({ data: proforma, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-2' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockUploadDocument).not.toHaveBeenCalled()
expect(mockRenderToBuffer).not.toHaveBeenCalled()
})
it('uses kreditfaktura filename when archiving a credit note', async () => {
const creditNote = makeInvoice({
id: 'inv-3',
invoice_number: 'F-2026011',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' })
enqueue({ data: null, error: null })
// Lookup of the original invoice's number for the credit note PDF
enqueue({ data: { invoice_number: 'F-2026010' }, error: null })
const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-3' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }),
expect.anything()
)
})
})
+53 -3
View File
@@ -1,11 +1,14 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { ensureInitialized } from '@/lib/init'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { EntityType, Invoice } from '@/types'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
ensureInitialized()
@@ -75,10 +78,10 @@ export async function POST(
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
}
// Fetch accounting method
// Fetch full company settings for PDF rendering and accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.select('*')
.eq('company_id', companyId)
.single()
@@ -109,6 +112,53 @@ export async function POST(
}
}
// Render and archive the PDF as underlag so it remains retrievable even if
// the invoice row is later cancelled. Mirrors the send route.
if (isRealInvoice && settings) {
try {
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
(a, b) => a.sort_order - b.sort_order
)
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
}
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer: invoice.customer as Customer,
items,
company: settings as CompanySettings,
originalInvoiceNumber,
})
)
const filename = invoice.credited_invoice_id
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
}, {
upload_source: 'system',
journal_entry_id: journalEntryId ?? undefined,
})
} catch (err) {
console.error('Failed to archive invoice PDF on mark-sent:', err)
}
}
return NextResponse.json({
success: true,
status: 'sent',
+1
View File
@@ -110,6 +110,7 @@ export async function POST(
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (originalInvoice) {