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