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:
@@ -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