Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
This commit is contained in:
@@ -202,7 +202,7 @@ describe('POST /api/invoices/[id]/book', () => {
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { document_attachment_id: 'document-1' }, error: null })
|
||||
enqueue({ data: 'document-1', error: null })
|
||||
|
||||
const { status } = await parseJsonResponse(await bookRequest())
|
||||
|
||||
@@ -213,5 +213,9 @@ describe('POST /api/invoices/[id]/book', () => {
|
||||
'document-1',
|
||||
'je-1',
|
||||
)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith(
|
||||
'latest_sent_invoice_delivery_document',
|
||||
{ p_company_id: 'company-1', p_invoice_id: 'inv-1' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,16 +126,10 @@ export const POST = withRouteContext(
|
||||
|
||||
// The send flow archived the exact delivered PDF before this deferred
|
||||
// journal entry existed. Attach the newest successful delivery snapshot now.
|
||||
const { data: deliveryDocument, error: deliveryDocumentError } = await supabase
|
||||
.from('invoice_deliveries')
|
||||
.select('document_attachment_id')
|
||||
.eq('invoice_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.not('document_attachment_id', 'is', null)
|
||||
.order('sent_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
|
||||
'latest_sent_invoice_delivery_document',
|
||||
{ p_company_id: companyId, p_invoice_id: id },
|
||||
)
|
||||
|
||||
if (deliveryDocumentError) {
|
||||
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
|
||||
@@ -145,18 +139,18 @@ export const POST = withRouteContext(
|
||||
code: 'PDF_LINK_FAILED',
|
||||
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
} else if (deliveryDocument?.document_attachment_id) {
|
||||
} else if (typeof deliveryDocumentId === 'string') {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
deliveryDocument.document_attachment_id,
|
||||
deliveryDocumentId,
|
||||
journalEntry.id,
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
|
||||
invoiceId: id,
|
||||
documentId: deliveryDocument.document_attachment_id,
|
||||
documentId: deliveryDocumentId,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_LINK_FAILED',
|
||||
|
||||
@@ -65,13 +65,14 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns minimized delivery metadata with masked recipient domains', async () => {
|
||||
it('returns minimized delivery evidence for the active company', async () => {
|
||||
const delivery = {
|
||||
id: 'delivery-1',
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
to_addresses: ['customer@example.com'],
|
||||
cc_addresses: [],
|
||||
cc_addresses: ['accounts@example.com'],
|
||||
bcc_addresses: ['archive@example.com'],
|
||||
reply_to: 'sender@example.com',
|
||||
from_name: 'Example AB',
|
||||
subject: 'Faktura F-1001',
|
||||
@@ -102,7 +103,7 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
to_addresses: ['***@example.com'],
|
||||
cc_addresses: [],
|
||||
cc_addresses: ['***@example.com'],
|
||||
provider: 'resend',
|
||||
error_code: null,
|
||||
document_attachment_id: 'document-1',
|
||||
@@ -110,15 +111,20 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
failed_at: null,
|
||||
created_at: '2026-07-22T10:29:59.000Z',
|
||||
}])
|
||||
expect(body.data[0]).not.toHaveProperty('bcc_addresses')
|
||||
expect(body.data[0]).not.toHaveProperty('reply_to')
|
||||
expect(body.data[0]).not.toHaveProperty('from_name')
|
||||
expect(body.data[0]).not.toHaveProperty('subject')
|
||||
expect(body.data[0]).not.toHaveProperty('body_text')
|
||||
expect(body.data[0]).not.toHaveProperty('body_html')
|
||||
expect(body.data[0]).not.toHaveProperty('subject')
|
||||
expect(body.data[0]).not.toHaveProperty('reply_to')
|
||||
expect(body.data[0]).not.toHaveProperty('provider_message_id')
|
||||
expect(body.data[0]).not.toHaveProperty('attachment_filename')
|
||||
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
|
||||
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_deliveries')
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith('list_invoice_delivery_summaries', {
|
||||
p_company_id: 'company-1',
|
||||
p_invoice_id: INVOICE_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,48 +2,38 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { InvoiceDelivery } from '@/types'
|
||||
import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types'
|
||||
|
||||
type DeliveryListRow = Pick<
|
||||
InvoiceDelivery,
|
||||
| 'id'
|
||||
| 'channel'
|
||||
| 'status'
|
||||
| 'to_addresses'
|
||||
| 'cc_addresses'
|
||||
| 'provider'
|
||||
| 'error_code'
|
||||
| 'document_attachment_id'
|
||||
| 'sent_at'
|
||||
| 'failed_at'
|
||||
| 'created_at'
|
||||
>
|
||||
interface InvoiceDeliverySummaryRow {
|
||||
id: string
|
||||
channel: InvoiceDeliveryChannel
|
||||
status: InvoiceDeliveryStatus
|
||||
to_addresses: string[]
|
||||
cc_addresses: string[]
|
||||
provider: string | null
|
||||
error_code: string | null
|
||||
document_attachment_id: string | null
|
||||
sent_at: string | null
|
||||
failed_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const DELIVERY_COLUMNS = [
|
||||
'id',
|
||||
'channel',
|
||||
'status',
|
||||
'to_addresses',
|
||||
'cc_addresses',
|
||||
'provider',
|
||||
'error_code',
|
||||
'document_attachment_id',
|
||||
'sent_at',
|
||||
'failed_at',
|
||||
'created_at',
|
||||
].join(', ')
|
||||
type MaskedRecipientAddress = string & { readonly __maskedRecipientAddress: true }
|
||||
|
||||
function maskRecipientDomain(address: string): string {
|
||||
const separator = address.lastIndexOf('@')
|
||||
if (separator <= 0 || separator === address.length - 1) return '***'
|
||||
return `***@${address.slice(separator + 1)}`
|
||||
interface MaskedInvoiceDeliverySummaryRow
|
||||
extends Omit<InvoiceDeliverySummaryRow, 'to_addresses' | 'cc_addresses'> {
|
||||
to_addresses: MaskedRecipientAddress[]
|
||||
cc_addresses: MaskedRecipientAddress[]
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/invoices/[id]/deliveries
|
||||
*
|
||||
* Returns minimized delivery metadata for an invoice. Exact message content,
|
||||
* provider identifiers, checksums, and full recipient addresses stay server-side.
|
||||
* BCC recipients, provider identifiers, checksums, and full recipient
|
||||
* addresses stay server-side. The database allow-list and masking boundary is
|
||||
* defined by list_invoice_delivery_summaries in migration 20260723003000; this
|
||||
* route masks returned addresses again as defense in depth.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.deliveries.list',
|
||||
@@ -67,20 +57,19 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: deliveries, error } = await supabase
|
||||
.from('invoice_deliveries')
|
||||
.select(DELIVERY_COLUMNS)
|
||||
.eq('invoice_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.neq('status', 'preparing')
|
||||
.order('created_at', { ascending: false })
|
||||
const { data: deliveries, error } = await supabase.rpc(
|
||||
'list_invoice_delivery_summaries',
|
||||
{ p_company_id: companyId, p_invoice_id: id },
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log.error('failed to list invoice deliveries', error, { invoiceId: id })
|
||||
throw error
|
||||
}
|
||||
|
||||
const minimized = ((deliveries || []) as unknown as DeliveryListRow[]).map((delivery) => ({
|
||||
const minimized: MaskedInvoiceDeliverySummaryRow[] = (
|
||||
(deliveries || []) as unknown as InvoiceDeliverySummaryRow[]
|
||||
).map((delivery) => ({
|
||||
id: delivery.id,
|
||||
channel: delivery.channel,
|
||||
status: delivery.status,
|
||||
@@ -100,3 +89,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function maskRecipientDomain(address: string): MaskedRecipientAddress {
|
||||
const separator = address.lastIndexOf('@')
|
||||
if (separator <= 0 || separator === address.length - 1) {
|
||||
return '***' as MaskedRecipientAddress
|
||||
}
|
||||
return `***@${address.slice(separator + 1)}` as MaskedRecipientAddress
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockEnsureInvoiceNumber = vi.fn()
|
||||
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
||||
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args),
|
||||
}))
|
||||
|
||||
const mockRenderToBuffer = vi.fn()
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||
@@ -86,6 +91,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
const company = makeCompanySettings({
|
||||
accounting_method: 'accrual',
|
||||
entity_type: 'enskild_firma',
|
||||
bankgiro: '123-4567',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
@@ -162,6 +168,43 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it.each(['SEK', 'EUR'] as const)(
|
||||
'rejects a %s invoice without a payment account before number allocation',
|
||||
async (currency) => {
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
...invoice,
|
||||
invoice_number: null,
|
||||
currency,
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
invoice_payment_accounts: {},
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
||||
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockRenderToBuffer).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
|
||||
enqueue({ data: invoice, error: null }) // fetch invoice
|
||||
enqueue({ data: company, error: null }) // settings
|
||||
@@ -181,6 +224,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-7')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
|
||||
supabase: mockSupabase,
|
||||
companyId: 'company-1',
|
||||
|
||||
@@ -17,6 +17,10 @@ import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type {
|
||||
@@ -97,14 +101,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
const customLines = linesResult.lines
|
||||
|
||||
// Assign invoice number now if this draft doesn't have one yet
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
log.error('failed to assign invoice number on mark-sent', err as Error)
|
||||
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
// Fetch full company settings for PDF rendering and accounting method
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -116,6 +112,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
|
||||
}
|
||||
|
||||
const invoiceCurrency = (invoice as Invoice).currency
|
||||
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
||||
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
|
||||
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
|
||||
requestId,
|
||||
details: { currency: invoiceCurrency },
|
||||
})
|
||||
}
|
||||
|
||||
// Assign the number only after all payment-instruction guards pass.
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
log.error('failed to assign invoice number on mark-sent', err as Error)
|
||||
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
|
||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||
let originalInvoice: CreditNoteOriginalInvoice | undefined
|
||||
@@ -359,8 +372,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
settings as CompanySettings,
|
||||
renderableInvoice.currency,
|
||||
{ paymentAccountRequired },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
@@ -425,14 +440,17 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'sent',
|
||||
journal_entry_id: journalEntryId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
: {}),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
status: 'sent',
|
||||
journal_entry_id: journalEntryId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
: {}),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'private, no-store' } },
|
||||
)
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ import { GET } from '../route'
|
||||
describe('GET /api/invoices/[id]/pdf', () => {
|
||||
const user = { id: 'user-1', email: 'owner@example.test' }
|
||||
const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' })
|
||||
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
|
||||
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
|
||||
const invoice = makeInvoice({
|
||||
id: 'invoice-1',
|
||||
invoice_number: '2621',
|
||||
@@ -77,6 +77,7 @@ describe('GET /api/invoices/[id]/pdf', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
|
||||
@@ -91,5 +92,36 @@ describe('GET /api/invoices/[id]/pdf', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
||||
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns 400 before rendering when a foreign payment account is missing', async () => {
|
||||
enqueue({ data: { ...invoice, currency: 'EUR' }, error: null })
|
||||
enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/invoice-1/pdf'),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(renderToBufferMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks PDF generation errors as private and non-cacheable', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/invoice-1/pdf'),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,12 +7,27 @@ import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
|
||||
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.pdf',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
async (request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// withRouteContext resolves companyId from the authenticated user's active
|
||||
// membership. Explicit company filters remain mandatory defense in depth.
|
||||
|
||||
// Fetch invoice with customer and items
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
@@ -26,7 +41,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice not found' },
|
||||
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
@@ -37,7 +55,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Company settings not found' },
|
||||
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
|
||||
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
|
||||
requestId,
|
||||
details: { currency: (invoice as Invoice).currency },
|
||||
}))
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
@@ -50,6 +78,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
@@ -61,8 +90,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// Generate PDF
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
company as CompanySettings,
|
||||
(invoice as Invoice).currency,
|
||||
{ paymentAccountRequired: invoiceRequiresPaymentAccount(invoice as Invoice) },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, invoice as Invoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
@@ -98,13 +129,14 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('attachment', filename),
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
'Cache-Control': 'private, no-store',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PDF generation error:', error)
|
||||
log.error('invoice PDF generation failed', error, { requestId, invoiceId: id })
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? getUserErrorMessage(error) : 'PDF generation failed' },
|
||||
{ status: 500 }
|
||||
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
|
||||
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
|
||||
to: string | string[]
|
||||
cc?: string | string[]
|
||||
bcc?: string | string[]
|
||||
subject: string
|
||||
html: string
|
||||
text: string
|
||||
@@ -69,6 +70,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
|
||||
...(await input.emailService.sendEmail({
|
||||
to: input.to,
|
||||
cc: input.cc,
|
||||
bcc: input.bcc,
|
||||
subject: input.subject,
|
||||
html: input.html,
|
||||
text: input.text,
|
||||
@@ -135,7 +137,7 @@ import { POST } from '../route'
|
||||
describe('POST /api/invoices/[id]/send', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
|
||||
const company = makeCompanySettings({ accounting_method: 'accrual' })
|
||||
const company = makeCompanySettings({ accounting_method: 'accrual', bankgiro: '123-4567' })
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'draft',
|
||||
@@ -316,6 +318,26 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
|
||||
})
|
||||
|
||||
it('returns 400 when the stored customer email is malformed', async () => {
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: 'inv-1',
|
||||
customer: makeCustomer({ email: 'not-an-email' }),
|
||||
items: [],
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when company settings not found', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
@@ -328,11 +350,155 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING')
|
||||
})
|
||||
|
||||
it.each(['SEK', 'EUR'] as const)(
|
||||
'does not allocate a number or send a %s invoice without a matching payment account',
|
||||
async (currency) => {
|
||||
const invoiceWithoutAccount = makeInvoice({
|
||||
...invoice,
|
||||
invoice_number: null,
|
||||
currency,
|
||||
})
|
||||
enqueue({ data: invoiceWithoutAccount, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
invoice_payment_accounts: {},
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects custom recipients from a non-admin company member before allocation', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', {
|
||||
method: 'POST',
|
||||
body: { additional_cc: ['external@test.se'] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('FORBIDDEN')
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a custom recipient collision before allocation', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', {
|
||||
method: 'POST',
|
||||
body: { additional_cc: ['KUND@test.se'] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { collisions: Array<{ conflicts_with: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.collisions).toEqual([
|
||||
expect.objectContaining({ conflicts_with: 'to' }),
|
||||
])
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a combined recipient set over the limit before allocation', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
invoice_email_cc_addresses: Array.from(
|
||||
{ length: 19 },
|
||||
(_, index) => `fixed-${index}@test.se`,
|
||||
),
|
||||
invoice_email_bcc_addresses: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', {
|
||||
method: 'POST',
|
||||
body: { additional_bcc: ['archive@test.se'] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { recipient_count: number } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
|
||||
expect(body.error.details.recipient_count).toBe(21)
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects fixed routing over the total limit without a custom-recipient role query', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
email: 'legacy@test.se',
|
||||
invoice_email_cc_addresses: Array.from(
|
||||
{ length: 19 },
|
||||
(_, index) => `fixed-${index}@test.se`,
|
||||
),
|
||||
invoice_email_bcc_addresses: ['archive@test.se'],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { recipient_count: number } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
|
||||
expect(body.error.details.recipient_count).toBe(21)
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends invoice email, updates status, creates journal entry for accrual', async () => {
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
invoice_email_cc_addresses: ['fixed-copy@test.se'],
|
||||
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// Authorize the per-send CC and BCC additions.
|
||||
enqueue({ data: { role: 'owner' }, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -344,22 +510,38 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
additional_cc: ['case-owner@test.se'],
|
||||
additional_bcc: ['extra-archive@test.se'],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
messageId: string
|
||||
recipient_counts: { to: number; cc: number }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.messageId).toBe('msg-1')
|
||||
expect(body.recipient_counts).toEqual({ to: 1, cc: 2 })
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
invoiceId: 'inv-1',
|
||||
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
|
||||
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
|
||||
}),
|
||||
)
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'kund@test.se',
|
||||
to: ['kund@test.se'],
|
||||
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
|
||||
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
|
||||
subject: 'Faktura F-2024001',
|
||||
})
|
||||
)
|
||||
@@ -381,6 +563,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'inv-1',
|
||||
currency: 'EUR',
|
||||
customer,
|
||||
items: (invoice.items ?? []).map((item) => ({
|
||||
...item,
|
||||
@@ -426,6 +609,9 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
}),
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(InvoicePDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ originalInvoiceNumber: 'F-2024001' }),
|
||||
)
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
@@ -516,7 +702,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
})
|
||||
|
||||
it('skips journal entry for cash method', async () => {
|
||||
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
|
||||
const cashCompany = makeCompanySettings({ accounting_method: 'cash', bankgiro: '123-4567' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: cashCompany, error: null })
|
||||
|
||||
|
||||
@@ -27,7 +27,19 @@ import {
|
||||
InvoiceDeliverySnapshotError,
|
||||
} from '@/lib/invoices/invoice-deliveries'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { SendInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
|
||||
import {
|
||||
EMAIL_PATTERN,
|
||||
exceedsInvoiceEmailRecipientLimit,
|
||||
findAdditionalInvoiceRecipientCollisions,
|
||||
invoiceEmailRecipientCount,
|
||||
resolveInvoiceEmailRecipients,
|
||||
} from '@/lib/invoices/email-recipients'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
@@ -118,7 +130,18 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
const linesResult = parseCustomIssuanceLines(rawBody)
|
||||
const bodyResult = SendInvoiceSchema.safeParse(rawBody ?? {})
|
||||
if (!bodyResult.success) {
|
||||
opLog.warn('send validation failed')
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltig förfrågan', details: bodyResult.error.flatten() },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const linesResult = parseCustomIssuanceLines(
|
||||
bodyResult.data.lines ? { lines: bodyResult.data.lines } : undefined,
|
||||
)
|
||||
if (!linesResult.ok) {
|
||||
if (linesResult.error === 'invalid_body') {
|
||||
opLog.warn('send validation failed')
|
||||
@@ -147,7 +170,7 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) {
|
||||
if (!customer.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) {
|
||||
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
|
||||
requestId,
|
||||
details: { customerId: customer.id },
|
||||
@@ -164,6 +187,72 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId })
|
||||
}
|
||||
|
||||
const invoiceCurrency = (invoice as Invoice).currency
|
||||
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
||||
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
|
||||
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', opLog, {
|
||||
requestId,
|
||||
details: { currency: invoiceCurrency },
|
||||
})
|
||||
}
|
||||
|
||||
const hasAdditionalRecipients =
|
||||
(bodyResult.data.additional_cc?.length ?? 0) > 0
|
||||
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0
|
||||
// Fixed recipients are owner/admin-approved company routing and apply to
|
||||
// every writable sender. Only a new per-send disclosure needs this fresh
|
||||
// role check. See .compliance/authorization-policy.md.
|
||||
if (hasAdditionalRecipients) {
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (membershipError) {
|
||||
opLog.error('failed to authorize custom invoice recipients', membershipError)
|
||||
return errorResponseFromCode('INTERNAL_ERROR', opLog, { requestId })
|
||||
}
|
||||
if (!membership || !['owner', 'admin'].includes(membership.role)) {
|
||||
return errorResponseFromCode('FORBIDDEN', opLog, {
|
||||
requestId,
|
||||
details: { required_roles: ['owner', 'admin'] },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const recipientInput = {
|
||||
to: customer.email,
|
||||
configuredCc: company.invoice_email_cc_addresses,
|
||||
configuredBcc: company.invoice_email_bcc_addresses,
|
||||
// This value comes from company settings or the authenticated sender. It
|
||||
// is fixed routing, not an arbitrary request-controlled recipient.
|
||||
legacyCc: company.email || user.email,
|
||||
additionalCc: bodyResult.data.additional_cc,
|
||||
additionalBcc: bodyResult.data.additional_bcc,
|
||||
}
|
||||
const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput)
|
||||
if (recipientCollisions.length > 0) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', opLog, {
|
||||
requestId,
|
||||
details: { field: 'recipients', collisions: recipientCollisions },
|
||||
})
|
||||
}
|
||||
const recipients = resolveInvoiceEmailRecipients(recipientInput)
|
||||
if (recipients.to.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
|
||||
requestId,
|
||||
details: { customerId: customer.id },
|
||||
})
|
||||
}
|
||||
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
|
||||
return errorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', opLog, {
|
||||
requestId,
|
||||
details: { recipient_count: invoiceEmailRecipientCount(recipients) },
|
||||
})
|
||||
}
|
||||
|
||||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
let originalInvoice: CreditNoteOriginalInvoice | undefined
|
||||
@@ -190,7 +279,11 @@ export const POST = withRouteContext(
|
||||
const isFreshAllocation = !invoice.invoice_number
|
||||
if (isFreshAllocation) {
|
||||
try {
|
||||
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
|
||||
const preflight = await prepareInvoicePdfRender(
|
||||
company as CompanySettings,
|
||||
(invoice as Invoice).currency,
|
||||
{ paymentAccountRequired },
|
||||
)
|
||||
await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
|
||||
@@ -253,8 +346,10 @@ export const POST = withRouteContext(
|
||||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
company as CompanySettings,
|
||||
renderableInvoice.currency,
|
||||
{ paymentAccountRequired },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
@@ -285,7 +380,6 @@ export const POST = withRouteContext(
|
||||
isCreditNote,
|
||||
})
|
||||
|
||||
const ccAddress = company.email || user.email
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
if (paymentLinkFailure) {
|
||||
// The failure string is a raw provider/DB message: log it, but the
|
||||
@@ -377,8 +471,9 @@ export const POST = withRouteContext(
|
||||
userId: user.id,
|
||||
invoiceId: id,
|
||||
deliveryId,
|
||||
to: customer.email,
|
||||
cc: ccAddress,
|
||||
to: recipients.to,
|
||||
cc: recipients.cc,
|
||||
bcc: recipients.bcc,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
@@ -575,15 +670,31 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
messageId: result.messageId,
|
||||
opLog.info('invoice sent', {
|
||||
deliveryId: result.deliveryId,
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
: {}),
|
||||
messageId: result.messageId,
|
||||
recipientCounts: {
|
||||
to: recipients.to.length,
|
||||
cc: recipients.cc.length,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email}`,
|
||||
messageId: result.messageId,
|
||||
deliveryId: result.deliveryId,
|
||||
recipient_counts: {
|
||||
to: recipients.to.length,
|
||||
cc: recipients.cc.length,
|
||||
},
|
||||
...(partialFailures.length > 0
|
||||
? { partial: true, partial_failures: partialFailures }
|
||||
: {}),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'private, no-store' } },
|
||||
)
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ import { POST } from '../route'
|
||||
describe('POST /api/invoices/preview-pdf', () => {
|
||||
const user = { id: 'user-1', email: 'owner@example.test' }
|
||||
const customer = makeCustomer({ id: 'customer-1', name: 'Kund ÅÄÖ AB' })
|
||||
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
|
||||
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
|
||||
const validBody = {
|
||||
customer_id: customer.id,
|
||||
invoice_number: '2621',
|
||||
@@ -88,9 +88,11 @@ describe('POST /api/invoices/preview-pdf', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns 404 when the customer does not exist', async () => {
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const response = await POST(
|
||||
@@ -99,11 +101,12 @@ describe('POST /api/invoices/preview-pdf', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns a descriptive UTF-8 filename for the PDF preview', async () => {
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: customer, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
|
||||
@@ -112,7 +115,41 @@ describe('POST /api/invoices/preview-pdf', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
||||
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
||||
})
|
||||
|
||||
it('returns 400 when a foreign payment account is missing', async () => {
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/preview-pdf', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, currency: 'EUR' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(renderToBufferMock).not.toHaveBeenCalled()
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('customers')
|
||||
})
|
||||
|
||||
it('marks preview generation errors as private and non-cacheable', async () => {
|
||||
enqueue({ data: company, error: null })
|
||||
enqueue({ data: customer, error: null })
|
||||
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,18 @@ import { getVatRules } from '@/lib/invoices/vat-rules'
|
||||
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
|
||||
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/invoices/preview-pdf
|
||||
@@ -14,7 +26,13 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy
|
||||
* Generates a preview PDF from form data without creating an invoice.
|
||||
* Returns the PDF as an inline blob for display in a new browser tab.
|
||||
*/
|
||||
export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => {
|
||||
export const POST = withRouteContext('invoice.preview_pdf', async (request, {
|
||||
supabase,
|
||||
user,
|
||||
companyId,
|
||||
log,
|
||||
requestId,
|
||||
}) => {
|
||||
const body = await request.json()
|
||||
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body
|
||||
|
||||
@@ -30,7 +48,40 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
})()
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Rader krävs' }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Rader krävs' },
|
||||
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const requestedCurrency = currency || 'SEK'
|
||||
|
||||
// Fetch and validate company payment settings before customer data. The
|
||||
// preview performs no writes, but a request that cannot be rendered should
|
||||
// still stop before processing customer details.
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företagsinställningar saknas' },
|
||||
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, {
|
||||
currency: requestedCurrency,
|
||||
document_type: docType,
|
||||
credited_invoice_id: null,
|
||||
})) {
|
||||
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
|
||||
requestId,
|
||||
details: { currency: requestedCurrency },
|
||||
}))
|
||||
}
|
||||
|
||||
// When customer_id is omitted, only allow the synthetic preview if the
|
||||
@@ -47,7 +98,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (countError || (count ?? 0) > 0) {
|
||||
return NextResponse.json({ error: 'Kunduppgifter krävs' }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunduppgifter krävs' },
|
||||
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
@@ -85,26 +139,17 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
.single()
|
||||
|
||||
if (customerError || !data) {
|
||||
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunden hittades inte' },
|
||||
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
customer = data as Customer
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
|
||||
}
|
||||
|
||||
// VAT rules are customer-type-driven and only know the customer side.
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
|
||||
// VAT registration gate: mirror the server-side write gate
|
||||
@@ -154,7 +199,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
due_date: due_date || new Date().toISOString().split('T')[0],
|
||||
delivery_date: delivery_date || null,
|
||||
status: 'draft',
|
||||
currency: currency || 'SEK',
|
||||
currency: requestedCurrency,
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
subtotal: isDeliveryNote ? 0 : subtotal,
|
||||
@@ -183,8 +228,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
try {
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
company as CompanySettings,
|
||||
previewInvoice.currency,
|
||||
{ paymentAccountRequired: invoiceRequiresPaymentAccount(previewInvoice) },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, previewInvoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
@@ -211,13 +258,14 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('inline', filename),
|
||||
'Cache-Control': 'private, no-store',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Preview PDF generation error:', error)
|
||||
log.error('invoice preview PDF generation failed', error, { requestId })
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
|
||||
{ status: 500 }
|
||||
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user