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) {