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:
@@ -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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user