Fix/usr fdbck ch (#1105)

* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
This commit is contained in:
Mattsson
2026-07-22 18:49:57 +02:00
committed by GitHub
parent 3e1ea29d02
commit 321e684523
58 changed files with 3742 additions and 285 deletions
@@ -12,6 +12,14 @@ vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const downloadMock = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
@@ -73,9 +81,8 @@ describe('GET /api/documents/[id]/inline', () => {
expect(body.error).toBe('Document not found')
})
it('returns 404 when the user is not a member of the document company', async () => {
enqueue({ data: makeDoc(), error: null }) // doc lookup
enqueue({ data: null, error: null }) // membership lookup
it('returns 404 when the document is outside the active company', async () => {
enqueue({ data: null, error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
@@ -83,7 +90,6 @@ describe('GET /api/documents/[id]/inline', () => {
it('returns 500 when the storage download fails', async () => {
enqueue({ data: makeDoc(), error: null })
enqueue({ data: { company_id: 'company-1' }, error: null })
downloadMock.mockResolvedValue({ data: null, error: { message: 'boom' } })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
@@ -92,7 +98,6 @@ describe('GET /api/documents/[id]/inline', () => {
it('streams the file with an RFC 5987 Content-Disposition for an NFD filename', async () => {
enqueue({ data: makeDoc(), error: null })
enqueue({ data: { company_id: 'company-1' }, error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
@@ -104,5 +109,6 @@ describe('GET /api/documents/[id]/inline', () => {
// ASCII fallback replaces the non-ASCII character.
expect(disposition).toContain('filename="kvitto f_rvaring.pdf"')
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
})
})
+47 -59
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
@@ -43,66 +43,54 @@ function resolveContentType(fileName: string, dbMimeType: string | null): string
const ext = fileName.toLowerCase().split('.').pop() ?? ''
return EXTENSION_MIME_MAP[ext] ?? dbMimeType ?? 'application/octet-stream'
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { user, supabase, error } = await requireAuth()
if (error) return error
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.inline',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { id } = await params
// Authorize via the auth-bound client and the active tenant. RLS remains
// the second layer, while the explicit company filter prevents a document
// from another membership being opened through a guessed identifier.
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, file_name, mime_type, storage_path')
.eq('id', id)
.eq('company_id', companyId)
.single()
// Authorize via the auth-bound client: RLS + explicit company filter
// through user_company_ids (defense in depth).
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, file_name, mime_type, storage_path')
.eq('id', id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Use the service-role client to read from the non-public bucket only after
// the active-company authorization check above has succeeded.
const serviceClient = createServiceClient()
const { data: blob, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
// Explicit membership check on top of RLS.
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', doc.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 },
)
}
if (!membership) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Use the service-role client to read from the non-public bucket.
const serviceClient = createServiceClient()
const { data: blob, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 }
)
}
return new NextResponse(blob, {
status: 200,
headers: {
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
// RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain
// combining marks (> 0xFF), which undici Headers reject as non-
// ByteString values; splicing the raw name here 500ed the route.
'Content-Disposition': contentDisposition('inline', doc.file_name),
'Cache-Control': 'private, max-age=300',
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
'X-Content-Type-Options': 'nosniff',
},
})
}
return new NextResponse(blob, {
status: 200,
headers: {
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
// RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain
// combining marks (> 0xFF), which undici Headers reject as non-
// ByteString values; splicing the raw name here 500ed the route.
'Content-Disposition': contentDisposition('inline', doc.file_name),
'Cache-Control': 'private, no-store',
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
'X-Content-Type-Options': 'nosniff',
},
})
},
)
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
}))
const mockLinkToJournalEntry = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -68,6 +73,7 @@ describe('POST /api/invoices/[id]/book', () => {
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockLinkToJournalEntry.mockResolvedValue({ id: 'document-1' })
})
it('returns 401 when not authenticated', async () => {
@@ -169,6 +175,7 @@ describe('POST /api/invoices/[id]/book', () => {
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
enqueue({ data: null, error: null })
const { status, body } = await parseJsonResponse<{
data: { journal_entry_id: string }
@@ -188,4 +195,23 @@ describe('POST /api/invoices/[id]/book', () => {
)
expect(mockCreateSchedules).toHaveBeenCalled()
})
it('links the delivered PDF to the deferred journal entry', async () => {
const invoice = makeUnbookedInvoice()
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { document_attachment_id: 'document-1' }, error: null })
const { status } = await parseJsonResponse(await bookRequest())
expect(status).toBe(200)
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'document-1',
'je-1',
)
})
})
+44 -1
View File
@@ -5,6 +5,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { CompanySettings, EntityType, Invoice, InvoiceItem } from '@/types'
// Statuses where the revenue entry can still be created afterwards. Paid
@@ -121,11 +122,53 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId })
}
const warnings: Array<{ code: string; message: string }> = []
// The send flow archived the exact delivered PDF before this deferred
// journal entry existed. Attach the newest successful delivery snapshot now.
const { data: deliveryDocument, error: deliveryDocumentError } = await supabase
.from('invoice_deliveries')
.select('document_attachment_id')
.eq('invoice_id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.not('document_attachment_id', 'is', null)
.order('sent_at', { ascending: false })
.limit(1)
.maybeSingle()
if (deliveryDocumentError) {
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
invoiceId: id,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
} else if (deliveryDocument?.document_attachment_id) {
try {
await linkToJournalEntry(
supabase,
companyId!,
deliveryDocument.document_attachment_id,
journalEntry.id,
)
} catch (err) {
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
invoiceId: id,
documentId: deliveryDocument.document_attachment_id,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
}
}
// Periodiseringar ride on the revenue entry, so they can only be created
// now. Non-blocking: the entry is committed (immutable); a schedule
// failure is surfaced as a warning and retried from the periodiseringar
// page.
const warnings: Array<{ code: string; message: string }> = []
try {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
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 }),
}))
import { GET } from '../route'
const INVOICE_ID = '550e8400-e29b-41d4-a716-446655440000'
describe('GET /api/invoices/[id]/deliveries', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'user@example.com' } },
})
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(
createMockRequest('/api/invoices/invoice-1/deliveries'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(401)
})
it('returns 400 when the invoice id is invalid', async () => {
const response = await GET(
createMockRequest('/api/invoices/not-a-uuid/deliveries'),
createMockRouteParams({ id: 'not-a-uuid' }),
)
expect(response.status).toBe(400)
})
it('returns 404 when the invoice is outside the active company', async () => {
enqueue({ data: null, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(404)
})
it('returns minimized delivery metadata with masked recipient domains', async () => {
const delivery = {
id: 'delivery-1',
channel: 'email',
status: 'sent',
to_addresses: ['customer@example.com'],
cc_addresses: [],
reply_to: 'sender@example.com',
from_name: 'Example AB',
subject: 'Faktura F-1001',
body_text: 'Hej! Här kommer fakturan.',
provider: 'resend',
provider_message_id: 'provider-1',
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
attachment_content_type: 'application/pdf',
attachment_sha256: 'abc123',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}
enqueue({ data: { id: INVOICE_ID }, error: null })
enqueue({ data: [delivery], error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<{ data: Array<Record<string, unknown>> }>(response)
expect(response.status).toBe(200)
expect(body.data).toEqual([{
id: 'delivery-1',
channel: 'email',
status: 'sent',
to_addresses: ['***@example.com'],
cc_addresses: [],
provider: 'resend',
error_code: null,
document_attachment_id: 'document-1',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}])
expect(body.data[0]).not.toHaveProperty('body_text')
expect(body.data[0]).not.toHaveProperty('body_html')
expect(body.data[0]).not.toHaveProperty('subject')
expect(body.data[0]).not.toHaveProperty('reply_to')
expect(body.data[0]).not.toHaveProperty('provider_message_id')
expect(body.data[0]).not.toHaveProperty('attachment_filename')
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_deliveries')
})
})
+102
View File
@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { InvoiceDelivery } from '@/types'
type DeliveryListRow = Pick<
InvoiceDelivery,
| 'id'
| 'channel'
| 'status'
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'sent_at'
| 'failed_at'
| 'created_at'
>
const DELIVERY_COLUMNS = [
'id',
'channel',
'status',
'to_addresses',
'cc_addresses',
'provider',
'error_code',
'document_attachment_id',
'sent_at',
'failed_at',
'created_at',
].join(', ')
function maskRecipientDomain(address: string): string {
const separator = address.lastIndexOf('@')
if (separator <= 0 || separator === address.length - 1) return '***'
return `***@${address.slice(separator + 1)}`
}
/**
* GET /api/invoices/[id]/deliveries
*
* Returns minimized delivery metadata for an invoice. Exact message content,
* provider identifiers, checksums, and full recipient addresses stay server-side.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (!z.string().uuid().safeParse(id).success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (invoiceError || !invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
const { data: deliveries, error } = await supabase
.from('invoice_deliveries')
.select(DELIVERY_COLUMNS)
.eq('invoice_id', id)
.eq('company_id', companyId)
.neq('status', 'preparing')
.order('created_at', { ascending: false })
if (error) {
log.error('failed to list invoice deliveries', error, { invoiceId: id })
throw error
}
const minimized = ((deliveries || []) as unknown as DeliveryListRow[]).map((delivery) => ({
id: delivery.id,
channel: delivery.channel,
status: delivery.status,
to_addresses: delivery.to_addresses.map(maskRecipientDomain),
cc_addresses: delivery.cc_addresses.map(maskRecipientDomain),
provider: delivery.provider,
error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id,
sent_at: delivery.sent_at,
failed_at: delivery.failed_at,
created_at: delivery.created_at,
}))
return NextResponse.json(
{ data: minimized },
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
)
@@ -73,6 +73,11 @@ vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockRecordManualInvoiceDelivery = vi.fn()
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
@@ -117,6 +122,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
journalEntryRequired: true,
failures: [],
})
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
})
it('returns 401 when not authenticated', async () => {
@@ -175,6 +181,12 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-7')
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: mockSupabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'inv-1',
})
expect(mockRenderToBuffer).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
@@ -183,7 +195,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
'user-1',
'company-1',
expect.objectContaining({
name: 'faktura-F-2026010.pdf',
name: 'Test Firma x Test AB Faktura nr F-2026010 20240615.pdf',
type: 'application/pdf',
}),
expect.objectContaining({
@@ -290,7 +302,9 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
expect.objectContaining({
name: 'Test Firma x Test AB Kreditfaktura nr KR-F-2026010 20240615.pdf',
}),
expect.anything()
)
})
+28 -3
View File
@@ -13,8 +13,10 @@ import {
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
@@ -371,9 +373,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
)
const filename = invoice.credited_invoice_id
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
const filename = invoicePdfFilename({
companyName: settings.company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote: !!invoice.credited_invoice_id,
})
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId, {
@@ -393,6 +401,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
if (statusFlipped) {
try {
await recordManualInvoiceDelivery({
supabase,
companyId,
userId: user.id,
invoiceId: id,
})
} catch (err) {
log.error('failed to record manual invoice delivery', err as Error)
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket kunde inte sparas i fakturans historik.',
})
}
}
if (!isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
makeInvoice,
} from '@/tests/helpers'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const renderToBufferMock = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
import { GET } from '../route'
describe('GET /api/invoices/[id]/pdf', () => {
const user = { id: 'user-1', email: 'owner@example.test' }
const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
const invoice = makeInvoice({
id: 'invoice-1',
invoice_number: '2621',
invoice_date: '2026-07-21',
customer,
items: [],
})
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await GET(
createMockRequest('/api/invoices/missing/pdf'),
createMockRouteParams({ id: 'missing' }),
)
expect(response.status).toBe(404)
})
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(200)
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
})
})
+12 -5
View File
@@ -3,6 +3,8 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -80,16 +82,21 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
// Return PDF as response
const isCreditNote = !!invoice.credited_invoice_id
const filenameNumber = invoice.invoice_number ?? `utkast-${String(invoice.id).slice(0, 8)}`
const filename = isCreditNote
? `kreditfaktura-${filenameNumber}.pdf`
: `faktura-${filenameNumber}.pdf`
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': pdfBuffer.length.toString(),
},
})
@@ -54,6 +54,47 @@ vi.mock('@/lib/email/service', () => ({
}),
}))
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
const mockLinkToJournalEntry = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
@@ -313,6 +354,9 @@ describe('POST /api/invoices/[id]/send', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.messageId).toBe('msg-1')
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kund@test.se',
@@ -385,7 +429,9 @@ describe('POST /api/invoices/[id]/send', () => {
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
expect.objectContaining({
filename: 'Test Firma x Test AB Kreditfaktura nr KR-F-2024001 20240615.pdf',
}),
],
}),
)
@@ -612,6 +658,36 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('does not call the provider when delivery history cannot be saved', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendTrackedInvoiceEmail.mockRejectedValueOnce(new Error('snapshot insert failed'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: unknown }>(response)
expect(status).toBe(500)
expect((body.error as { code: string }).code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not allocate an invoice number when delivery reservation fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockReserveInvoiceDelivery.mockRejectedValueOnce(new Error('reservation failed'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(mockSendTrackedInvoiceEmail).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on malformed lines before any email is sent', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/send', {
+91 -41
View File
@@ -13,13 +13,19 @@ import {
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -201,6 +207,22 @@ export const POST = withRouteContext(
}
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId: companyId!,
userId: user.id,
invoiceId: id,
})
} catch (err) {
opLog.error('failed to reserve invoice delivery before number assignment', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
// Allocate the F-series number. Idempotent: retries reuse the same number.
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
@@ -253,17 +275,15 @@ export const POST = withRouteContext(
company: company as CompanySettings,
}
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'proforma') {
filename = `proformafaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'delivery_note') {
filename = `foljesedel-${invoice.invoice_number}.pdf`
} else {
filename = `faktura-${invoice.invoice_number}.pdf`
}
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
const ccAddress = company.email || user.email
const partialFailures: Array<{ step: string; reason: string }> = []
@@ -344,24 +364,45 @@ export const POST = withRouteContext(
}
}
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
attachments: [
{
filename,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
})
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId: companyId!,
userId: user.id,
invoiceId: id,
deliveryId,
to: customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name,
filename,
pdfBuffer,
})
} catch (err) {
opLog.error('failed to persist invoice delivery snapshot before send', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
if (!result.success) {
if (result.trackingWarning) {
opLog.warn('invoice send: failed delivery snapshot not reconciled', {
invoiceId: id,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
requestId,
@@ -369,6 +410,17 @@ export const POST = withRouteContext(
})
}
if (result.trackingWarning) {
opLog.warn('invoice send: delivery snapshot not finalised', {
invoiceId: id,
deliveryId: result.deliveryId,
})
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket sparades men kunde inte färdigmarkeras i historiken.',
})
}
// From here on the invoice has reached the customer. Failures in the
// follow-up steps degrade the response to PARTIAL: the user gets a
// success toast with a sub-warning, and the audit trail records exactly
@@ -489,22 +541,19 @@ export const POST = withRouteContext(
}
}
if (statusFlipped && isRealInvoice) {
if (statusFlipped && isRealInvoice && createdJournalEntryId) {
try {
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: createdJournalEntryId,
})
await linkToJournalEntry(
supabase,
companyId!,
result.documentId,
createdJournalEntryId,
)
} catch (err) {
opLog.error('failed to store invoice PDF as underlag', err as Error)
opLog.error('failed to link archived invoice PDF to journal entry', err as Error)
partialFailures.push({
step: 'pdf_archive',
reason: 'Fakturans PDF kunde inte arkiveras.',
step: 'pdf_link',
reason: 'Fakturans arkiverade PDF kunde inte kopplas till verifikationen.',
})
}
}
@@ -530,6 +579,7 @@ export const POST = withRouteContext(
success: true,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId,
deliveryId: result.deliveryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
} from '@/tests/helpers'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
const renderToBufferMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
prepareInvoicePdfRender: vi.fn(async (company: unknown) => ({ branding: {}, company })),
buildSwishQrDataUrl: vi.fn().mockResolvedValue(null),
buildPaymentLinkQrDataUrl: vi.fn().mockResolvedValue(null),
}))
import { POST } from '../route'
describe('POST /api/invoices/preview-pdf', () => {
const user = { id: 'user-1', email: 'owner@example.test' }
const customer = makeCustomer({ id: 'customer-1', name: 'Kund ÅÄÖ AB' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
const validBody = {
customer_id: customer.id,
invoice_number: '2621',
invoice_date: '2026-07-21',
due_date: '2026-08-20',
currency: 'SEK',
items: [{
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 14000,
vat_rate: 25,
}],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(401)
})
it('returns 400 when invoice rows are missing', async () => {
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...validBody, items: [] },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(400)
})
it('returns 404 when the customer does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(404)
})
it('returns a descriptive UTF-8 filename for the PDF preview', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: company, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/pdf')
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
})
})
+11 -1
View File
@@ -4,6 +4,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
/**
@@ -196,11 +198,19 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
paymentLinkQrDataUrl,
})
)
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: customer.name,
invoiceNumber: previewInvoice.invoice_number,
invoiceId: previewInvoice.id,
invoiceDate: previewInvoice.invoice_date,
documentType: previewInvoice.document_type,
})
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
'Content-Disposition': contentDisposition('inline', filename),
},
})
} catch (error) {
@@ -41,6 +41,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
}),
}))
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
createInvoiceJournalEntry as mockedCreateEntry,
@@ -127,6 +132,7 @@ beforeEach(() => {
scopes: ['invoices:write'],
mode: 'live',
})
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
})
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
@@ -156,6 +162,12 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
expect(body.data.invoice_number).toBe('2026-0042')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: expect.anything(),
companyId: COMPANY_ID,
userId: USER_ID,
invoiceId: INVOICE_ID,
})
})
it('returns 409 INVOICE_UPDATE_NOT_DRAFT when the invoice is already sent', async () => {
@@ -41,6 +41,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import { eventBus } from '@/lib/events'
import type { EntityType, Invoice } from '@/types'
@@ -350,7 +351,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Step 4: emit invoice.sent. Best-effort; escalate to error if it
// Step 4: preserve the manual delivery transition in immutable history.
try {
await recordManualInvoiceDelivery({
supabase: ctx.supabase,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
})
} catch (err) {
ctx.log.error('mark-sent: delivery history insert failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'DELIVERY_HISTORY_NOT_RECORDED',
message: 'Invoice was marked sent, but the manual delivery transition could not be added to its history.',
})
}
// Step 5: emit invoice.sent. Best-effort; escalate to error if it
// fails (downstream webhook delivery and audit trails depend on this).
try {
await eventBus.emit({
@@ -44,6 +44,7 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import { GET as pdf } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
@@ -125,7 +126,7 @@ beforeEach(() => {
})
describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
it('returns a PDF for a sent invoice with the faktura-<number> filename', async () => {
it('returns a PDF for a sent invoice with a descriptive filename', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -141,11 +142,12 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="faktura-2026-0042.pdf"')
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf')
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
})
it('uses utkast-<id-slice>.pdf filename for drafts', async () => {
it('uses an identifiable descriptive filename for drafts', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -163,15 +165,11 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
)
expect(res.status).toBe(200)
// Same composition as the dashboard's internal pdf route: the
// "faktura-" prefix is preserved, the number slot is the "utkast-<slice>"
// placeholder.
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="faktura-utkast-bbbbbbbb.pdf"',
)
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Faktura utkast-bbbbbbbb 20260512.pdf')
})
it('uses kreditfaktura-<number>.pdf for credit notes and embeds original number', async () => {
it('identifies credit notes in the filename and embeds the original number', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -196,9 +194,8 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="kreditfaktura-2026-0099.pdf"',
)
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Kreditfaktura nr 2026-0099 20260512.pdf')
// The template received the original number: verify via the InvoicePDF mock call.
const call = (mockRender.mock.calls[0]?.[0] as unknown) as { props?: unknown } | undefined
expect(call).toBeDefined()
@@ -10,7 +10,7 @@
* PDF is still rendered: useful for "preview before send" workflows.
* - Sent / paid / overdue / cancelled / credit notes: full PDF with the
* persisted invoice number.
* - Credit notes: filename uses `kreditfaktura-` prefix and the original
* - Credit notes: the filename identifies the document as a kreditfaktura and the original
* invoice's löpnummer is embedded (ML 17 kap 22-23§ back-reference).
* - Delivery notes: PDF is permitted (read-only, no compliance side effect).
*
@@ -21,6 +21,8 @@ import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
@@ -44,7 +46,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/invoices/:id/pdf',
summary: 'Download the rendered invoice PDF.',
description:
'Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-<number>.pdf for sent invoices, kreditfaktura-<number>.pdf for credit notes, utkast-<id-slice>.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.',
'Returns the invoice as application/pdf. The descriptive filename contains company, customer, document type, invoice number or draft identifier, and invoice date. This endpoint is byte-equivalent to the dashboard download.',
useWhen:
'You need to fetch an invoice PDF for archival, forwarding to a customer outside the Accounted send flow, or attaching to an external workflow.',
doNotUseFor:
@@ -175,21 +177,22 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
}
const isCreditNote = !!typed.credited_invoice_id
const filenameNumber = typed.invoice_number ?? `utkast-${invoiceId.slice(0, 8)}`
const filename = isCreditNote
? `kreditfaktura-${filenameNumber}.pdf`
: typed.document_type === 'proforma'
? `proformafaktura-${filenameNumber}.pdf`
: typed.document_type === 'delivery_note'
? `följesedel-${filenameNumber}.pdf`
: `faktura-${filenameNumber}.pdf`
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: typed.customer?.name,
invoiceNumber: typed.invoice_number,
invoiceId,
invoiceDate: typed.invoice_date,
documentType: typed.document_type,
isCreditNote,
})
const uint8Array = new Uint8Array(pdfBuffer)
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': String(pdfBuffer.length),
'X-Request-Id': ctx.requestId,
},
@@ -43,6 +43,7 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn().mockResolvedValue({}),
linkToJournalEntry: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@react-pdf/renderer', () => ({
@@ -63,6 +64,42 @@ vi.mock('@/lib/email/service', async (importOriginal) => {
}
})
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>...</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('plain text'),
@@ -216,6 +253,18 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(body.data.sent_to).toBe('billing@acme.test')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: COMPANY_ID, invoiceId: INVOICE_ID }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
filename: 'Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf',
}),
],
}),
)
})
it('returns 503 when email service is not configured', async () => {
@@ -56,8 +56,14 @@ import {
generateInvoiceEmailText,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { eventBus } from '@/lib/events'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -326,6 +332,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
)
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase: ctx.supabase,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
})
} catch (err) {
ctx.log.error('invoices.send: delivery reservation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
// Step 6: allocate F-series number atomically.
try {
await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed as Invoice)
@@ -423,32 +448,59 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Step 8: send the email. Delivery notes AND credit notes were rejected
// earlier so docType is 'invoice' or 'proforma' here.
const docType = typed.document_type ?? 'invoice'
const filename =
docType === 'proforma'
? `proformafaktura-${finalInvoiceNumber}.pdf`
: `faktura-${finalInvoiceNumber}.pdf`
const filename = invoicePdfFilename({
companyName: settings.company_name,
customerName: customer.name,
invoiceNumber: finalInvoiceNumber,
invoiceId: typed.id,
invoiceDate: typed.invoice_date,
documentType: typed.document_type,
})
const ccAddress = settings.email ?? null
const emailData = { invoice: renderableInvoice, customer, company: settings }
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress ?? undefined,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: settings.email ?? undefined,
fromName: settings.company_name ?? undefined,
attachments: [
{
filename,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
})
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase: ctx.supabase,
emailService,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress ?? undefined,
subject,
html,
text,
replyTo: settings.email ?? undefined,
fromName: settings.company_name ?? undefined,
filename,
pdfBuffer,
})
} catch (err) {
ctx.log.error('invoices.send: delivery snapshot failed before email', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
if (!result.success) {
if (result.trackingWarning) {
ctx.log.warn('invoices.send: failed delivery snapshot not reconciled', {
invoiceId,
companyId: ctx.companyId,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
ctx.log.error('invoices.send: email provider failed', new Error(result.error ?? 'unknown'), {
invoiceId,
companyId: ctx.companyId,
@@ -462,6 +514,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Email has been delivered. Subsequent failures surface as warnings.
const warnings: { code: string; message: string }[] = []
if (result.trackingWarning) {
ctx.log.warn('invoices.send: delivery snapshot not finalized', {
invoiceId,
companyId: ctx.companyId,
deliveryId: result.deliveryId,
})
warnings.push({
code: 'DELIVERY_HISTORY_FINALIZE_FAILED',
message: 'The delivery snapshot exists but could not be finalized. Reconcile the pending delivery record.',
})
}
if (paymentLinkFailure) {
warnings.push({ code: 'PAYMENT_LINK_FAILED', message: paymentLinkFailure })
}
@@ -547,32 +611,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Step 9c: archive the PDF as underlag.
if (isRealInvoice) {
// Step 9c: link the already archived exact delivery PDF to the entry.
if (isRealInvoice && journalEntryId) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(
await linkToJournalEntry(
ctx.supabase,
ctx.userId,
ctx.companyId!,
{
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
},
{
upload_source: 'system',
journal_entry_id: journalEntryId ?? undefined,
},
result.documentId,
journalEntryId,
)
} catch (err) {
ctx.log.error('invoices.send: PDF archival failed', err as Error, {
ctx.log.error('invoices.send: archived PDF journal link failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'PDF_ARCHIVE_FAILED',
message: 'Invoice was sent but the PDF could not be archived as underlag. Manual upload required for BFL 7 kap retention.',
code: 'PDF_JOURNAL_LINK_FAILED',
message: 'The exact sent PDF was archived but could not be linked to the journal entry.',
})
}
}