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
+27 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { contentDisposition } from '../content-disposition'
import { contentDisposition, contentDispositionFilename } from '../content-disposition'
describe('contentDisposition', () => {
it('passes a plain ASCII filename through unchanged in both forms', () => {
@@ -45,12 +45,13 @@ describe('contentDisposition', () => {
).not.toThrow()
})
it('neutralizes quote and CRLF header injection', () => {
const header = contentDisposition('attachment', 'evil"\r\nSet-Cookie: x=y.pdf')
it('neutralizes header delimiters and CRLF injection', () => {
const header = contentDisposition('attachment', 'evil";\\\r\nSet-Cookie: x=y.pdf')
expect(header).not.toContain('\r')
expect(header).not.toContain('\n')
expect(header).toContain('filename="evil___Set-Cookie: x=y.pdf"')
expect(header).toContain('filename="evil_____Set-Cookie: x=y.pdf"')
// The extended form percent-encodes them instead of emitting them raw.
expect(header).toContain('%22%3B%5C')
expect(header).toContain('%0D%0A')
})
@@ -89,3 +90,25 @@ describe('contentDisposition', () => {
expect(() => new Headers({ 'Content-Disposition': header })).not.toThrow()
})
})
describe('contentDispositionFilename', () => {
it('prefers and decodes the UTF-8 filename', () => {
const header = contentDisposition(
'attachment',
'Företag x Kund AB Faktura nr 2621 20260721.pdf',
)
expect(contentDispositionFilename(header))
.toBe('Företag x Kund AB Faktura nr 2621 20260721.pdf')
})
it('falls back to the quoted ASCII filename', () => {
expect(contentDispositionFilename('attachment; filename="faktura-2621.pdf"'))
.toBe('faktura-2621.pdf')
})
it('returns null for a missing or malformed filename', () => {
expect(contentDispositionFilename(null)).toBeNull()
expect(contentDispositionFilename('attachment')).toBeNull()
})
})
+19 -3
View File
@@ -28,9 +28,9 @@ export function contentDisposition(
const normalized = filename.toWellFormed().normalize('NFC')
// ASCII fallback for the quoted-string form: anything outside printable
// ASCII, plus the quoted-string specials " and \, becomes _. This also
// neutralizes CR/LF header injection.
const fallback = normalized.replace(/[^\x20-\x7e]|["\\]/g, '_')
// ASCII, plus structurally significant header characters, becomes _. This
// also neutralizes CR/LF header injection.
const fallback = normalized.replace(/[^\x20-\x7e]|["\\;]/g, '_')
// RFC 5987 value-chars: encodeURIComponent covers everything except
// ! ' ( ) * which it leaves bare but RFC 5987 forbids unencoded.
@@ -41,3 +41,19 @@ export function contentDisposition(
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`
}
/** Read the preferred UTF-8 filename from a Content-Disposition header. */
export function contentDispositionFilename(header: string | null): string | null {
if (!header) return null
const extended = header.match(/(?:^|;)\s*filename\*=UTF-8''([^;]*)/i)
if (extended?.[1]) {
try {
return decodeURIComponent(extended[1])
} catch {
// Fall through to the ASCII quoted-string form.
}
}
return header.match(/(?:^|;)\s*filename="([^"]*)"/i)?.[1] ?? null
}
@@ -78,6 +78,98 @@ describe('proposeSendLines', () => {
})
})
it('does not create zero-value revenue rows for informational invoice items', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem(),
makeItem({
id: 'text-1',
line_type: 'text',
description: 'Information shown on the invoice',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
expect(lines.some((line) =>
(parseFloat(line.debit_amount) || 0) === 0
&& (parseFloat(line.credit_amount) || 0) === 0
)).toBe(false)
})
it('ignores informational rows when selecting the legacy invoice VAT treatment', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem({ vat_rate: undefined }),
makeItem({
id: 'text-1',
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
})
it('returns no booking proposal for an invoice containing only informational rows', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
total: 0,
subtotal: 0,
vat_amount: 0,
items: [
makeItem({
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines).toEqual([])
})
it('rejects a non-zero invoice containing only informational rows', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem({
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines).toEqual([])
})
it('credit note uses positive amounts on the reversed sides', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
+30 -9
View File
@@ -121,14 +121,22 @@ function buildSendLines(
// Build credit lines per VAT rate group
const creditLines: FormLine[] = []
const accountingItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
if (invoice.items && invoice.items.length > 0) {
const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
// Existing informational rows are never a valid source for an invoice-level
// amount. Returning no proposal keeps an inconsistent text-only invoice from
// producing a debit-only entry; the user must correct its economic rows.
if (accountingItems.length === 0 && (invoice.items?.length ?? 0) > 0) {
return []
}
if (accountingItems.length > 0) {
const hasPerLineVat = accountingItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy: single rate from invoice level
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0)
const subtotal = accountingItems.reduce((sum, item) => sum + item.line_total, 0)
creditLines.push({
account_number: revenueAccount,
debit_amount: '',
@@ -136,7 +144,7 @@ function buildSendLines(
line_description: desc,
})
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
const totalVat = accountingItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
@@ -149,7 +157,7 @@ function buildSendLines(
} else {
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of invoice.items) {
for (const item of accountingItems) {
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
@@ -182,7 +190,7 @@ function buildSendLines(
}
}
}
} else {
} else if (!invoice.items || invoice.items.length === 0) {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
@@ -207,7 +215,7 @@ function buildSendLines(
const deductionLines: FormLine[] = []
let deductionTotal = 0
for (const item of invoice.items ?? []) {
for (const item of accountingItems) {
if (!item.deduction_type) continue
const deduction = computeDeduction({
unit_price: item.unit_price,
@@ -225,13 +233,26 @@ function buildSendLines(
})
}
// Text-only and other informational invoice rows carry zero totals. They
// must not become misleading 30xx rows in the booking preview.
const nonZeroCreditLines = creditLines.filter(
(line) => roundOre(parseFloat(line.credit_amount) || 0) !== 0,
)
// Debit: 1510 customer portion plus 1513 Skatteverket portion.
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const totalCredits = nonZeroCreditLines.reduce(
(sum, line) => sum + (parseFloat(line.credit_amount) || 0),
0,
)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const customerReceivable = roundOre(debitAmount - deductionTotal)
if (customerReceivable === 0 && deductionLines.length === 0 && nonZeroCreditLines.length === 0) {
return []
}
lines.push({
account_number: '1510',
debit_amount: toFormAmount(customerReceivable),
@@ -240,7 +261,7 @@ function buildSendLines(
})
lines.push(...deductionLines)
lines.push(...creditLines)
lines.push(...nonZeroCreditLines)
return lines
}
+1
View File
@@ -23,6 +23,7 @@ export interface SendEmailOptions {
export interface SendEmailResult {
success: boolean
provider?: string
messageId?: string
error?: string
}
+5
View File
@@ -900,6 +900,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'E-postleverantören kunde inte skicka meddelandet.',
message_en: 'The email provider could not deliver the message.',
},
INVOICE_SEND_SNAPSHOT_FAILED: {
httpStatus: 500,
message_sv: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.',
message_en: 'The delivery snapshot could not be saved. No email was sent.',
},
INVOICE_SEND_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv:
@@ -0,0 +1,389 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
async function insertInvoice(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Delivery History Customer')`,
[customerId, userId, companyId],
)
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5,
'2026-07-22', '2026-08-21', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', 'sent')`,
[invoiceId, userId, companyId, customerId, `F-${randomUUID().slice(0, 8)}`],
)
return invoiceId
}
async function insertDocument(userId: string, companyId: string): Promise<string> {
const documentId = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash)
VALUES ($1, $2, $3, $4, 'invoice.pdf', 1024, 'application/pdf', $5)`,
[
documentId,
userId,
companyId,
`documents/${userId}/${documentId}.pdf`,
'a'.repeat(64),
],
)
return documentId
}
async function insertManualDelivery(params: {
userId: string
companyId: string
invoiceId: string
}): Promise<string> {
const deliveryId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status, sent_at)
VALUES ($1, $2, $3, $4, 'manual', 'marked_sent', now())`,
[deliveryId, params.userId, params.companyId, params.invoiceId],
)
return deliveryId
}
async function insertPendingEmailDelivery(params: {
userId: string
companyId: string
invoiceId: string
documentId: string
retentionExpiresAt?: string
}): Promise<string> {
const deliveryId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status,
to_addresses, cc_addresses, reply_to, from_name, subject,
body_text, body_html, document_attachment_id, attachment_filename,
attachment_content_type, attachment_sha256, retention_expires_at)
VALUES ($1, $2, $3, $4, 'email', 'pending',
ARRAY['customer@example.com'], ARRAY['copy@example.com'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6, $7)`,
[
deliveryId,
params.userId,
params.companyId,
params.invoiceId,
params.documentId,
'a'.repeat(64),
params.retentionExpiresAt ?? null,
],
)
return deliveryId
}
describe('invoice_deliveries.pg: immutable delivery evidence', () => {
it('allows only a pending to terminal transition and then locks the row', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', provider = 'resend',
provider_message_id = 'provider-1', sent_at = now()
WHERE id = $1`,
[deliveryId],
)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries SET body_text = 'tampered' WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
await getPool().query(`DELETE FROM public.invoice_deliveries WHERE id = $1`, [deliveryId])
const retained = await getPool().query(
`SELECT id FROM public.invoice_deliveries WHERE id = $1`,
[deliveryId],
)
const deleteAudit = await getPool().query(
`SELECT old_state
FROM public.audit_log
WHERE table_name = 'invoice_deliveries'
AND record_id = $1
AND action = 'SECURITY_EVENT'
ORDER BY created_at DESC
LIMIT 1`,
[deliveryId],
)
expect(retained.rowCount).toBe(1)
expect(deleteAudit.rowCount).toBe(1)
expect(deleteAudit.rows[0].old_state).not.toHaveProperty('body_text')
expect(deleteAudit.rows[0].old_state).not.toHaveProperty('to_addresses')
})
it('blocks payload changes while finalizing a pending email', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(), subject = 'Changed subject'
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('rejects invoice and document references from another company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const invoiceA = await insertInvoice(a.userId, a.companyId)
const documentB = await insertDocument(b.userId, b.companyId)
await expect(
insertManualDelivery({
userId: b.userId,
companyId: b.companyId,
invoiceId: invoiceA,
}),
).rejects.toThrow(/invoice delivery invoice\/company mismatch/i)
await expect(
insertPendingEmailDelivery({
userId: a.userId,
companyId: a.companyId,
invoiceId: invoiceA,
documentId: documentB,
}),
).rejects.toThrow(/invoice delivery document\/company mismatch/i)
})
it('prevents deletion of the exact PDF after a successful send', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[deliveryId],
)
await expect(
getPool().query(`DELETE FROM public.document_attachments WHERE id = $1`, [documentId]),
).rejects.toThrow(/exact PDF sent with a customer invoice/i)
})
it('isolates delivery history by company through RLS', async () => {
const a = await seedCompany()
const b = await seedCompany()
const deliveryA = await insertManualDelivery({
userId: a.userId,
companyId: a.companyId,
invoiceId: await insertInvoice(a.userId, a.companyId),
})
await insertManualDelivery({
userId: b.userId,
companyId: b.companyId,
invoiceId: await insertInvoice(b.userId, b.companyId),
})
const visibleIds = await withUserContext(a.userId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT id FROM public.invoice_deliveries WHERE company_id = ANY($1::uuid[])`,
[[a.companyId, b.companyId]],
)
return result.rows.map((row) => row.id)
})
expect(visibleIds).toEqual([deliveryA])
})
it('denies inserts to a viewer', async () => {
const { userId, companyId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
const invoiceId = await insertInvoice(userId, companyId)
await expect(
withUserContext(viewerId, async (client) => {
await client.query(
`INSERT INTO public.invoice_deliveries
(user_id, company_id, invoice_id, channel, status, sent_at)
VALUES ($1, $2, $3, 'manual', 'marked_sent', now())`,
[viewerId, companyId, invoiceId],
)
}),
).rejects.toThrow(/row-level security|policy/i)
})
it('reserves one preparing attempt and promotes it to the exact pending payload', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status)
VALUES ($1, $2, $3, $4, 'email', 'preparing')`,
[deliveryId, userId, companyId, invoiceId],
)
await expect(
getPool().query(
`INSERT INTO public.invoice_deliveries
(user_id, company_id, invoice_id, channel, status)
VALUES ($1, $2, $3, 'email', 'preparing')`,
[userId, companyId, invoiceId],
),
).rejects.toThrow(/duplicate key|unique constraint/i)
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'pending',
to_addresses = ARRAY['customer@example.com'],
subject = 'Faktura F-1001',
body_text = 'Exact plain text',
body_html = '<p>Exact HTML</p>',
document_attachment_id = $2,
attachment_filename = 'invoice.pdf',
attachment_content_type = 'application/pdf',
attachment_sha256 = $3
WHERE id = $1`,
[deliveryId, documentId, 'a'.repeat(64)],
)
const result = await getPool().query(
`SELECT status, retention_expires_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(result.rows[0].status).toBe('pending')
expect(result.rows[0].retention_expires_at).toBeTruthy()
})
it('allows a failed attempt to release and delete its unsent PDF', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'failed', failed_at = now(),
error_code = 'provider_failed', document_attachment_id = NULL
WHERE id = $1`,
[deliveryId],
)
await getPool().query(
`DELETE FROM public.document_attachments WHERE id = $1`,
[documentId],
)
const document = await getPool().query(
`SELECT id FROM public.document_attachments WHERE id = $1`,
[documentId],
)
expect(document.rowCount).toBe(0)
})
it('redacts expired delivery PII and keeps metadata-only audit state', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
retentionExpiresAt: '2000-01-01',
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[deliveryId],
)
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query(
`SELECT to_addresses, body_text, subject, provider_message_id,
attachment_filename, attachment_sha256, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(delivery.rows[0]).toMatchObject({
to_addresses: [],
body_text: null,
subject: null,
provider_message_id: null,
attachment_filename: null,
attachment_sha256: null,
})
expect(delivery.rows[0].pii_redacted_at).toBeTruthy()
const audit = await getPool().query(
`SELECT new_state
FROM public.audit_log
WHERE table_name = 'invoice_deliveries'
AND record_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[deliveryId],
)
expect(audit.rows[0].new_state).not.toHaveProperty('body_text')
expect(audit.rows[0].new_state).not.toHaveProperty('to_addresses')
})
it('uses restrictive parent foreign keys for immutable delivery evidence', async () => {
const constraints = await getPool().query<{ conname: string; confdeltype: string }>(
`SELECT conname, confdeltype
FROM pg_constraint
WHERE conrelid = 'public.invoice_deliveries'::regclass
AND conname IN (
'invoice_deliveries_company_id_fkey',
'invoice_deliveries_user_id_fkey'
)
ORDER BY conname`,
)
expect(constraints.rows).toEqual([
{ conname: 'invoice_deliveries_company_id_fkey', confdeltype: 'r' },
{ conname: 'invoice_deliveries_user_id_fkey', confdeltype: 'r' },
])
})
})
@@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService } from '@/lib/email/service'
const mockUploadDocument = vi.fn()
const mockDeleteDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
}))
import {
InvoiceDeliverySnapshotError,
recordManualInvoiceDelivery,
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '../invoice-deliveries'
function makeSupabase(options?: {
insertData?: Record<string, unknown> | null
insertError?: { message: string; code?: string } | null
existingData?: Record<string, unknown> | null
snapshotData?: Record<string, unknown> | null
snapshotError?: { message: string } | null
terminalError?: { message: string } | null
}) {
const insertResult = {
data: options?.insertData === undefined ? { id: 'delivery-1' } : options.insertData,
error: options?.insertError ?? null,
}
const updateResults = [
{
data: options?.snapshotData === undefined ? { id: 'delivery-1' } : options.snapshotData,
error: options?.snapshotError ?? null,
},
{ data: null, error: options?.terminalError ?? null },
]
const insertSpy = vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn().mockResolvedValue(insertResult),
})),
}))
const updateSpy = vi.fn(() => {
const result = updateResults.shift() ?? { data: null, error: null }
const chain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
select: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
then: (resolve: (value: typeof result) => void) => void
} = {
eq: vi.fn(),
select: vi.fn(),
single: vi.fn().mockResolvedValue(result),
then: (resolve) => resolve(result),
}
chain.eq.mockReturnValue(chain)
chain.select.mockReturnValue(chain)
return chain
})
const existingResult = { data: options?.existingData ?? null, error: null }
const selectChain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
} = {
eq: vi.fn(),
maybeSingle: vi.fn().mockResolvedValue(existingResult),
}
selectChain.eq.mockReturnValue(selectChain)
const selectSpy = vi.fn(() => selectChain)
const from = vi.fn(() => ({ insert: insertSpy, update: updateSpy, select: selectSpy }))
return {
supabase: { from } as unknown as SupabaseClient,
insertSpy,
updateSpy,
}
}
function makeInput(supabase: SupabaseClient, emailService: EmailService) {
return {
supabase,
emailService,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
deliveryId: 'delivery-1',
to: 'customer@example.com',
cc: ['accounting@example.com'],
replyTo: 'sender@example.com',
fromName: 'Example AB',
subject: 'Faktura F-1001',
html: '<p>Hej!</p>',
text: 'Hej!',
filename: 'faktura-f-1001.pdf',
pdfBuffer: Buffer.from('exact-pdf'),
}
}
function makeEmailService(sendEmail: ReturnType<typeof vi.fn>): EmailService {
return { isConfigured: () => true, sendEmail }
}
describe('invoice delivery tracking', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUploadDocument.mockResolvedValue({
id: 'document-1',
sha256_hash: 'sha256-exact-pdf',
})
mockDeleteDocument.mockResolvedValue({ ok: true })
})
it('persists the exact payload before sending and records provider success', async () => {
const { supabase, updateSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: true,
provider: 'resend',
messageId: 'provider-message-1',
})
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
status: 'pending',
to_addresses: ['customer@example.com'],
cc_addresses: ['accounting@example.com'],
subject: 'Faktura F-1001',
body_text: 'Hej!',
body_html: '<p>Hej!</p>',
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
attachment_sha256: 'sha256-exact-pdf',
}))
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Faktura F-1001',
text: 'Hej!',
html: '<p>Hej!</p>',
attachments: [expect.objectContaining({
filename: 'faktura-f-1001.pdf',
content: Buffer.from('exact-pdf'),
})],
}))
expect(updateSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
status: 'sent',
provider: 'resend',
provider_message_id: 'provider-message-1',
}))
expect(result).toMatchObject({
success: true,
deliveryId: 'delivery-1',
documentId: 'document-1',
})
})
it('does not call the provider when the immutable snapshot cannot be saved', async () => {
const { supabase } = makeSupabase({
snapshotData: null,
snapshotError: { message: 'update failed' },
})
const sendEmail = vi.fn()
await expect(
sendTrackedInvoiceEmail(makeInput(supabase, makeEmailService(sendEmail))),
).rejects.toBeInstanceOf(InvoiceDeliverySnapshotError)
expect(sendEmail).not.toHaveBeenCalled()
expect(mockDeleteDocument).toHaveBeenCalledWith(
supabase,
'company-1',
'document-1',
)
})
it('records a failed provider attempt without changing the saved payload', async () => {
const { supabase, updateSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: false,
provider: 'resend',
messageId: 'provider-returned-on-failure',
error: 'provider rejected the request',
})
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
provider: 'resend',
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
}))
expect(mockDeleteDocument).toHaveBeenCalledWith(supabase, 'company-1', 'document-1')
expect(result.success).toBe(false)
})
it('surfaces a warning if a successful provider result cannot be finalized', async () => {
const { supabase } = makeSupabase({ terminalError: { message: 'update failed' } })
const sendEmail = vi.fn().mockResolvedValue({ success: true })
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('finalize_failed')
})
it('reuses an existing preparing reservation after a unique conflict', async () => {
const { supabase } = makeSupabase({
insertData: null,
insertError: { message: 'duplicate', code: '23505' },
existingData: { id: 'delivery-existing' },
})
await expect(reserveInvoiceDelivery({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
})).resolves.toBe('delivery-existing')
})
it('records manual delivery without inventing recipient or content details', async () => {
const manualDelivery = {
id: 'delivery-1',
channel: 'manual',
status: 'marked_sent',
}
const { supabase, insertSpy } = makeSupabase({ insertData: manualDelivery })
await recordManualInvoiceDelivery({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
sentAt: '2026-07-22T10:30:00.000Z',
})
expect(insertSpy).toHaveBeenCalledWith({
company_id: 'company-1',
user_id: 'user-1',
invoice_id: 'invoice-1',
channel: 'manual',
status: 'marked_sent',
sent_at: '2026-07-22T10:30:00.000Z',
})
})
})
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { invoicePdfFilename } from '../pdf-filename'
describe('invoicePdfFilename', () => {
it('includes company, customer, document type, number, and invoice date', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})).toBe('Oppy x Kund AB Faktura nr 2621 20260721.pdf')
})
it('uses the correct label for credit notes and other document types', () => {
const base = {
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: '42',
invoiceDate: '2026-07-21',
}
expect(invoicePdfFilename({ ...base, isCreditNote: true }))
.toContain('Kreditfaktura nr 42')
expect(invoicePdfFilename({ ...base, documentType: 'proforma' }))
.toContain('Proformafaktura nr 42')
expect(invoicePdfFilename({ ...base, documentType: 'delivery_note' }))
.toContain('Följesedel nr 42')
})
it('keeps drafts identifiable without inventing an invoice number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceId: 'bbbbbbbb-1111-2222-3333-cccccccccccc',
invoiceDate: '2026-07-21',
})).toBe('Oppy x Kund AB Faktura utkast-bbbbbbbb 20260721.pdf')
})
it('removes characters that are unsafe in cross-platform filenames', () => {
expect(invoicePdfFilename({
companyName: 'Oppy / Sverige',
customerName: 'Kund: "Nord" * AB',
invoiceNumber: '../26/21',
invoiceDate: '2026-07-21',
})).toBe('Oppy Sverige x Kund Nord AB Faktura nr .. 26 21 20260721.pdf')
})
it('falls back when company and customer names are empty', () => {
expect(invoicePdfFilename({
companyName: ' ',
customerName: null,
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})).toBe('Företag x Kund Faktura nr 2621 20260721.pdf')
})
it('keeps multibyte filenames within common filesystem byte limits', () => {
const filename = invoicePdfFilename({
companyName: '🚀'.repeat(60),
customerName: '漢'.repeat(60),
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})
expect(Buffer.byteLength(filename, 'utf8')).toBeLessThanOrEqual(255)
expect(filename).toMatch(/Faktura nr 2621 20260721\.pdf$/)
})
})
@@ -45,6 +45,42 @@ 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]),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
@@ -74,6 +110,7 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
linkToJournalEntry: vi.fn().mockResolvedValue(undefined),
}))
describe('computeNextRunDate', () => {
@@ -175,8 +212,15 @@ describe('executeRecurringSchedule auto-send', () => {
const client = supabase as unknown as SupabaseClient
const today = new Date('2026-07-06T06:30:00Z')
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual' })
const customer = makeCustomer({
id: 'cust-1',
name: 'Kund ÅÄÖ AB',
email: 'kund@test.se',
})
const company = makeCompanySettings({
company_name: 'Oppy Sverige',
accounting_method: 'accrual',
})
function makeSchedule() {
return {
@@ -223,6 +267,7 @@ describe('executeRecurringSchedule auto-send', () => {
return {
id: 'inv-1',
invoice_number: 'F-1',
invoice_date: '2026-07-06',
status: 'draft',
document_type: 'invoice',
currency: 'SEK',
@@ -283,6 +328,9 @@ describe('executeRecurringSchedule auto-send', () => {
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
)
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
expect.anything(),
@@ -302,6 +350,11 @@ describe('executeRecurringSchedule auto-send', () => {
expect(mockInvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({
attachments: [expect.objectContaining({
filename: 'Oppy Sverige x Kund ÅÄÖ AB Faktura nr F-1 20260706.pdf',
})],
}))
})
it('a payment link failure never blocks the send', async () => {
+256
View File
@@ -0,0 +1,256 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
import { deleteDocument, uploadDocument } from '@/lib/core/documents/document-service'
import type { InvoiceDelivery } from '@/types'
const PDF_CONTENT_TYPE = 'application/pdf'
export class InvoiceDeliverySnapshotError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvoiceDeliverySnapshotError'
}
}
export interface TrackedInvoiceEmailInput {
supabase: SupabaseClient
emailService: EmailService
companyId: string
userId: string
invoiceId: string
deliveryId: string
to: string | string[]
cc?: string | string[]
replyTo?: string
fromName?: string
subject: string
html: string
text: string
filename: string
pdfBuffer: Buffer
}
export interface TrackedInvoiceEmailResult extends SendEmailResult {
deliveryId: string
documentId: string
trackingWarning?: 'finalize_failed' | 'failure_record_failed' | 'failure_cleanup_failed'
}
function addresses(value?: string | string[]): string[] {
if (!value) return []
return Array.isArray(value) ? value : [value]
}
/**
* Persist a reusable delivery attempt before allocating an invoice number.
* The unique preparing row is also the concurrency lock for one invoice send.
*/
export async function reserveInvoiceDelivery(args: {
supabase: SupabaseClient
companyId: string
userId: string
invoiceId: string
}): Promise<string> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'email',
status: 'preparing',
})
.select('id')
.single()
if (data?.id) return data.id
if ((error as { code?: string } | null)?.code === '23505') {
const { data: existing, error: existingError } = await args.supabase
.from('invoice_deliveries')
.select('id')
.eq('company_id', args.companyId)
.eq('invoice_id', args.invoiceId)
.eq('status', 'preparing')
.maybeSingle()
if (!existingError && existing?.id) return existing.id
}
throw new InvoiceDeliverySnapshotError(
`Failed to reserve invoice delivery: ${error?.message || 'unknown error'}`,
)
}
export async function sendTrackedInvoiceEmail(
input: TrackedInvoiceEmailInput,
): Promise<TrackedInvoiceEmailResult> {
const {
supabase,
emailService,
companyId,
userId,
invoiceId,
deliveryId,
to,
cc,
replyTo,
fromName,
subject,
html,
text,
filename,
pdfBuffer,
} = input
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
const document = await uploadDocument(
supabase,
userId,
companyId,
{
name: filename,
buffer: pdfArrayBuffer,
type: PDF_CONTENT_TYPE,
},
{ upload_source: 'system' },
)
const { data: delivery, error: deliveryError } = await supabase
.from('invoice_deliveries')
.update({
status: 'pending',
to_addresses: addresses(to),
cc_addresses: addresses(cc),
reply_to: replyTo || null,
from_name: fromName || null,
subject,
body_text: text,
body_html: html,
document_attachment_id: document.id,
attachment_filename: filename,
attachment_content_type: PDF_CONTENT_TYPE,
attachment_sha256: document.sha256_hash,
})
.eq('id', deliveryId)
.eq('company_id', companyId)
.eq('invoice_id', invoiceId)
.eq('status', 'preparing')
.select('*')
.single()
if (deliveryError || !delivery) {
try {
await deleteDocument(supabase, companyId, document.id)
} catch {
// Best-effort cleanup only. The send must remain blocked even if the
// unlinked archive cannot be removed after a snapshot insert failure.
}
throw new InvoiceDeliverySnapshotError(
`Failed to persist invoice delivery snapshot: ${deliveryError?.message || 'unknown error'}`,
)
}
const emailOptions: SendEmailOptions = {
to,
cc,
subject,
html,
text,
replyTo,
fromName,
attachments: [
{
filename,
content: pdfBuffer,
contentType: PDF_CONTENT_TYPE,
},
],
}
const result = await emailService.sendEmail(emailOptions)
if (!result.success) {
const { error: failureRecordError } = await supabase
.from('invoice_deliveries')
.update({
status: 'failed',
provider: result.provider || null,
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
failed_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
let cleanupFailed = false
if (!failureRecordError) {
try {
const cleanup = await deleteDocument(supabase, companyId, document.id)
cleanupFailed = !cleanup.ok
} catch {
cleanupFailed = true
}
}
return {
...result,
deliveryId: delivery.id,
documentId: document.id,
...(failureRecordError
? { trackingWarning: 'failure_record_failed' as const }
: cleanupFailed
? { trackingWarning: 'failure_cleanup_failed' as const }
: {}),
}
}
const { error: finalizeError } = await supabase
.from('invoice_deliveries')
.update({
status: 'sent',
provider: result.provider || null,
provider_message_id: result.messageId || null,
sent_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
return {
...result,
deliveryId: delivery.id,
documentId: document.id,
...(finalizeError ? { trackingWarning: 'finalize_failed' as const } : {}),
}
}
export async function recordManualInvoiceDelivery(args: {
supabase: SupabaseClient
companyId: string
userId: string
invoiceId: string
sentAt?: string
}): Promise<InvoiceDelivery> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'manual',
status: 'marked_sent',
sent_at: args.sentAt || new Date().toISOString(),
})
.select('*')
.single()
if (error || !data) {
throw new InvoiceDeliverySnapshotError(
`Failed to persist manual invoice delivery: ${error?.message || 'unknown error'}`,
)
}
return data as InvoiceDelivery
}
+85
View File
@@ -0,0 +1,85 @@
import type { InvoiceDocumentType } from '@/types'
const MAX_NAME_PART_LENGTH = 60
const MAX_NUMBER_PART_LENGTH = 40
const MAX_FILENAME_BYTES = 255
interface InvoicePdfFilenameInput {
companyName?: string | null
customerName?: string | null
invoiceNumber?: string | null
invoiceId?: string | null
invoiceDate?: string | null
documentType?: InvoiceDocumentType | null
isCreditNote?: boolean
}
function safeFilenamePart(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = (value ?? '')
.toWellFormed()
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/[ .]+$/g, '')
.trim()
if (!normalized) return fallback
return Array.from(normalized).slice(0, maxLength).join('').replace(/[ .]+$/g, '') || fallback
}
function documentLabel(documentType: InvoiceDocumentType, isCreditNote: boolean): string {
if (isCreditNote) return 'Kreditfaktura'
if (documentType === 'proforma') return 'Proformafaktura'
if (documentType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
function utf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
function fitFilename(companyName: string, customerName: string, suffix: string): string {
const company = Array.from(companyName)
const customer = Array.from(customerName)
const build = () => `${company.join('')} x ${customer.join('')} ${suffix}`
while (utf8ByteLength(build()) > MAX_FILENAME_BYTES && (company.length > 1 || customer.length > 1)) {
if (utf8ByteLength(company.join('')) >= utf8ByteLength(customer.join('')) && company.length > 1) {
company.pop()
} else if (customer.length > 1) {
customer.pop()
} else {
company.pop()
}
}
return build()
}
/**
* Build a descriptive, cross-platform-safe PDF filename for an invoice document.
*
* Example: `Oppy x Kund AB Faktura nr 2621 20260721.pdf`.
*/
export function invoicePdfFilename({
companyName,
customerName,
invoiceNumber,
invoiceId,
invoiceDate,
documentType = 'invoice',
isCreditNote = false,
}: InvoicePdfFilenameInput): string {
const company = safeFilenamePart(companyName, 'Företag', MAX_NAME_PART_LENGTH)
const customer = safeFilenamePart(customerName, 'Kund', MAX_NAME_PART_LENGTH)
const label = documentLabel(documentType ?? 'invoice', isCreditNote)
// The cross-platform filename is descriptive only. The invoice body retains
// the authoritative number and credit-note reference, including separators.
const number = invoiceNumber
? `nr ${safeFilenamePart(invoiceNumber, 'okänd', MAX_NUMBER_PART_LENGTH)}`
: `utkast-${safeFilenamePart(invoiceId?.slice(0, 8), 'utan-nummer', MAX_NUMBER_PART_LENGTH)}`
const compactDate = (invoiceDate ?? '').replace(/[^0-9]/g, '').slice(0, 8)
const suffix = [label, number, compactDate].filter(Boolean).join(' ') + '.pdf'
return fitFilename(company, customer, suffix)
}
+76 -28
View File
@@ -16,6 +16,7 @@ import { eventBus } from '@/lib/events'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
@@ -34,7 +35,11 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import { createLogger } from '@/lib/logger'
import type {
Invoice,
@@ -452,6 +457,22 @@ async function sendInvoiceFromSchedule(
throw new Error('company settings missing: cannot send invoice')
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId,
userId,
invoiceId: invoice.id,
})
} catch (err) {
log.error('failed to reserve recurring invoice delivery', err as Error, {
invoiceId: invoice.id,
companyId,
})
return false
}
const items = (invoice.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Auto-create an online payment link (extension-provided, e.g. Stripe) so
@@ -492,22 +513,53 @@ async function sendInvoiceFromSchedule(
}),
)
const emailData = { invoice, customer: invoice.customer, company }
const filename = `faktura-${invoice.invoice_number}.pdf`
const emailData = { invoice: renderableInvoice, customer: invoice.customer, company }
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: invoice.customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
})
const ccAddress = company.email || undefined
const result = await emailService.sendEmail({
to: invoice.customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.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,
emailService,
companyId,
userId,
invoiceId: invoice.id,
deliveryId,
to: invoice.customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name ?? undefined,
filename,
pdfBuffer,
})
} catch (err) {
log.error('failed to persist recurring invoice delivery before send', err as Error, {
invoiceId: invoice.id,
})
return false
}
if (result.trackingWarning) {
log.error(
'recurring invoice delivery snapshot requires reconciliation',
new Error(result.trackingWarning),
{ invoiceId: invoice.id, deliveryId: result.deliveryId },
)
}
if (!result.success) {
log.error(
@@ -551,19 +603,15 @@ async function sendInvoiceFromSchedule(
}
}
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(
supabase,
userId,
companyId,
{ name: filename, buffer: pdfArrayBuffer, type: 'application/pdf' },
{ upload_source: 'system', journal_entry_id: journalEntryId },
)
} catch (err) {
log.error('failed to archive recurring invoice PDF', err as Error, {
invoiceId: invoice.id,
})
if (journalEntryId) {
try {
await linkToJournalEntry(supabase, companyId, result.documentId, journalEntryId)
} catch (err) {
log.error('failed to link recurring invoice PDF to journal entry', err as Error, {
invoiceId: invoice.id,
documentId: result.documentId,
})
}
}
await eventBus.emit({
@@ -54,6 +54,13 @@ vi.mock('@/lib/transactions/categorize-core', async () => {
}
})
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
reserveInvoiceDelivery: vi.fn().mockResolvedValue('delivery-1'),
sendTrackedInvoiceEmail: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { parseSIEFile } from '@/lib/import/sie-parser'
@@ -173,6 +180,43 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
expect(result.http_status).toBe(409)
expect(result.error).toContain('Credit notes must be issued')
})
it('records delivery history when a regular invoice is marked as sent', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: 'F-2026001',
credited_invoice_id: null,
}),
error: null,
})
enqueue({ data: null, error: null }) // status update
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: null, error: null }) // dispatcher update
const op = makePendingOp({
operation_type: 'mark_invoice_sent',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('committed')
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
})
})
})
// ─── post_annual_depreciation ───────────────────────────────────────
+103 -25
View File
@@ -69,11 +69,17 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
recordManualInvoiceDelivery,
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
@@ -1543,6 +1549,23 @@ async function commitSendInvoice(
}
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId,
userId,
invoiceId,
})
} catch (err) {
log.error('failed to reserve invoice delivery before agent number assignment', err as Error, {
companyId,
userId,
invoiceId,
})
return { error: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.', status: 500 }
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
@@ -1570,25 +1593,58 @@ async function commitSendInvoice(
)
const isCreditNote = !!invoice.credited_invoice_id
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 || userEmail
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
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 emailData = { invoice: renderableInvoice, customer, company: company as CompanySettings }
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId,
userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name,
filename,
pdfBuffer,
})
} catch (err) {
log.error('failed to persist invoice delivery snapshot before agent send', err as Error, {
companyId,
userId,
invoiceId,
})
return { error: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.', status: 500 }
}
if (result.trackingWarning) {
log.warn('agent invoice delivery snapshot requires reconciliation', {
companyId,
userId,
invoiceId,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
@@ -1610,18 +1666,22 @@ async function commitSendInvoice(
}
}
if (isRealInvoice) {
if (isRealInvoice && createdJournalEntryId) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, companyId, {
name: filename, buffer: pdfArrayBuffer, type: 'application/pdf',
}, { upload_source: 'system', journal_entry_id: createdJournalEntryId })
await linkToJournalEntry(supabase, companyId, result.documentId, createdJournalEntryId)
} catch { /* non-blocking */ }
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
return {
data: {
message: `Invoice ${invoice.invoice_number} sent to ${customer.email}`,
...(result.trackingWarning
? { warning: 'Delivery history requires reconciliation.' }
: {}),
},
}
}
async function commitMarkInvoiceSent(
@@ -1659,6 +1719,18 @@ async function commitMarkInvoiceSent(
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
let deliveryHistoryWarning: string | undefined
try {
await recordManualInvoiceDelivery({ supabase, companyId, userId, invoiceId })
} catch (err) {
log.error('failed to persist manual invoice delivery from pending operation', err as Error, {
companyId,
userId,
invoiceId,
})
deliveryHistoryWarning = 'Fakturan markerades som skickad men utskickshistoriken kunde inte sparas.'
}
const { data: settings } = await supabase
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
@@ -1681,7 +1753,13 @@ async function commitMarkInvoiceSent(
}
}
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
return {
data: {
status: 'sent',
journal_entry_id: journalEntryId,
...(deliveryHistoryWarning ? { warning: deliveryHistoryWarning } : {}),
},
}
}
async function commitMatchTransactionInvoice(
+3
View File
@@ -792,6 +792,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'invoice_items', file: 'invoice_items.json', via: { parent: 'invoices', fk: 'invoice_id' } },
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
{ name: 'invoice_reminders', file: 'invoice_reminders.json' },
// Delivery metadata proves which recipient received the archived PDF and
// when, so it is räkenskapsinformation alongside the invoice itself.
{ name: 'invoice_deliveries', file: 'invoice_deliveries.json', orderBy: 'created_at' },
{ name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' },
// Supplier invoicing
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },