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