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