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:
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user