From d1267c37afa2c300fc0eef3ce71357e9d326a8c9 Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Tue, 28 Apr 2026 13:03:53 +0200
Subject: [PATCH] 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
---
app/(dashboard)/invoices/[id]/page.tsx | 9 +
.../[id]/mark-sent/__tests__/route.test.ts | 225 ++++++++++++++++++
app/api/invoices/[id]/mark-sent/route.ts | 56 ++++-
app/api/invoices/[id]/send/route.ts | 1 +
4 files changed, 288 insertions(+), 3 deletions(-)
create mode 100644 app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx
index e4daf80c..633647f7 100644
--- a/app/(dashboard)/invoices/[id]/page.tsx
+++ b/app/(dashboard)/invoices/[id]/page.tsx
@@ -954,6 +954,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
Ta bort fakturautkast
Ä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 ? (
+
+ Löpnummer {invoice.invoice_number} är redan reserverat och kommer att bli ett permanent hopp i fakturaserien.
+
+ ) : (
+
+ Inget löpnummer har tilldelats — fakturaserien påverkas inte.
+
+ )}
diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
new file mode 100644
index 00000000..820d0954
--- /dev/null
+++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
@@ -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()
+ )
+ })
+})
diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts
index d8406893..8ceb8045 100644
--- a/app/api/invoices/[id]/mark-sent/route.ts
+++ b/app/api/invoices/[id]/mark-sent/route.ts
@@ -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',
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 3fc2c340..9178f350 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -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) {