Fix/usr fdbck ch (#1105)

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

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
This commit is contained in:
Mattsson
2026-07-22 18:49:57 +02:00
committed by GitHub
parent 3e1ea29d02
commit 321e684523
58 changed files with 3742 additions and 285 deletions
@@ -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.',
})
}
}