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:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
@@ -22,12 +22,14 @@ vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
}))
vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({
createAnnualReportVersion: vi.fn(),
hasStatementIntegrityErrors: vi.fn(),
listAnnualReportVersions: vi.fn(),
}))
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import {
createAnnualReportVersion,
hasStatementIntegrityErrors,
listAnnualReportVersions,
} from '@/lib/bokslut/arsredovisning/version-service'
import { GET, POST } from '../route'
@@ -57,6 +59,7 @@ function setup() {
vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
validation: { ok: true },
} as never)
vi.mocked(hasStatementIntegrityErrors).mockReturnValue(false)
return mock
}
@@ -134,6 +137,40 @@ describe('annual report versions route', () => {
)
})
it.each(['snapshot', 'finalize'] as const)(
'rejects an inconsistent report before creating a %s version',
async (action) => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
vi.mocked(hasStatementIntegrityErrors).mockReturnValue(true)
vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
validation: {
ok: false,
issues: [{ code: 'AR-RESULT-MISMATCH', severity: 'error' }],
},
} as never)
const { status, body } = await parseJsonResponse<{
error: { code: string; message: string; message_en: string }
}>(
await POST(
createMockRequest('/x', { method: 'POST', body: { action } }),
params,
),
)
expect(status).toBe(409)
expect(body.error).toEqual(
expect.objectContaining({
code: 'ARSREDOVISNING_INCOMPLETE',
message: expect.any(String),
message_en: expect.any(String),
}),
)
expect(createAnnualReportVersion).not.toHaveBeenCalled()
},
)
it('accepts a VD as the fastställelseintyg signer', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
@@ -8,6 +8,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import {
createAnnualReportVersion,
hasStatementIntegrityErrors,
listAnnualReportVersions,
} from '@/lib/bokslut/arsredovisning/version-service'
@@ -83,16 +84,17 @@ export const POST = withRouteContext(
}
: undefined,
})
if (hasStatementIntegrityErrors(model)) {
return errorResponseFromCode('ARSREDOVISNING_INCOMPLETE', log, {
requestId,
details: model.validation,
})
}
if (validation.data.action === 'finalize' && !model.validation.ok) {
return NextResponse.json(
{
error: {
code: 'ARSREDOVISNING_INCOMPLETE',
details: model.validation,
},
},
{ status: 409 },
)
return errorResponseFromCode('ARSREDOVISNING_INCOMPLETE', log, {
requestId,
details: model.validation,
})
}
const data = await createAnnualReportVersion(
validation.data.action === 'finalize' ? createServiceClient() : supabase,
@@ -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' },
)
})
})
+7 -13
View File
@@ -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,
})
})
})
+38 -41
View File
@@ -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',
+35 -17
View File
@@ -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')
})
})
+38 -6
View File
@@ -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 })
+125 -14
View File
@@ -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')
})
})
+68 -20
View File
@@ -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 }
)
}
})
@@ -1,9 +1,28 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase } = createQueuedMockSupabase()
const { mockLogInfo, mockLogWarn, mockLogError } = vi.hoisted(() => ({
mockLogInfo: vi.fn(),
mockLogWarn: vi.fn(),
mockLogError: vi.fn(),
}))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: mockLogInfo,
warn: mockLogWarn,
error: mockLogError,
child: vi.fn().mockReturnThis(),
}),
}))
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const {
supabase: archiveSupabase,
enqueue: enqueueArchive,
reset: resetArchive,
} = createQueuedMockSupabase()
const createServiceClientMock = vi.fn(() => archiveSupabase)
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -20,6 +39,10 @@ vi.mock('@/lib/reports/full-archive-export', () => ({
estimateArchiveSize: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => createServiceClientMock(),
}))
import {
generateFullArchive,
estimateArchiveSize,
@@ -43,7 +66,11 @@ function unauthed() {
beforeEach(() => {
vi.clearAllMocks()
reset()
resetArchive()
authed()
enqueue({ data: { role: 'admin' }, error: null })
enqueueArchive({ data: { role: 'admin' }, error: null })
})
describe('GET /api/reports/full-archive', () => {
@@ -56,6 +83,58 @@ describe('GET /api/reports/full-archive', () => {
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 for a member without archive-audit access', async () => {
reset()
enqueue({ data: { role: 'member' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/reports/full-archive')),
)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(mockLogWarn).toHaveBeenCalledWith('full archive access denied', {
userId: 'user-1',
companyId: 'company-1',
role: 'member',
})
expect(createServiceClientMock).not.toHaveBeenCalled()
expect(mockEstimate).not.toHaveBeenCalled()
expect(mockGenerate).not.toHaveBeenCalled()
})
it('rejects when the verified user is not a member of the selected company', async () => {
reset()
resetArchive()
enqueue({ data: { role: 'admin' }, error: null })
enqueueArchive({ data: null, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/reports/full-archive')),
)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(mockEstimate).not.toHaveBeenCalled()
expect(mockGenerate).not.toHaveBeenCalled()
})
it('returns 500 when the service-role membership verification fails', async () => {
reset()
resetArchive()
enqueue({ data: { role: 'admin' }, error: null })
enqueueArchive({ data: null, error: new Error('database unavailable') })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/reports/full-archive')),
)
expect(status).toBe(500)
expect(body.error.code).toBe('INTERNAL_ERROR')
expect(mockEstimate).not.toHaveBeenCalled()
expect(mockGenerate).not.toHaveBeenCalled()
})
it('returns estimate-only response when ?estimate=1', async () => {
mockEstimate.mockResolvedValue({
total_bytes: 10_000_000,
@@ -63,23 +142,24 @@ describe('GET /api/reports/full-archive', () => {
document_count: 7,
})
const response = await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { estimate: '1', scope: 'all' },
}),
)
const { status, body } = await parseJsonResponse<{
data: {
total_bytes: number
size_limit_bytes: number
within_limit: boolean
}
}>(
await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { estimate: '1', scope: 'all' },
})
)
)
}>(response)
expect(status).toBe(200)
expect(body.data.total_bytes).toBe(10_000_000)
expect(body.data.within_limit).toBe(true)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'company-1', 'all', undefined)
expect(mockGenerate).not.toHaveBeenCalled()
})
@@ -102,6 +182,7 @@ describe('GET /api/reports/full-archive', () => {
}>(response)
expect(status).toBe(413)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(body.error).toBe('archive_too_large')
expect(body.size_bytes).toBe(200 * 1024 * 1024)
expect(body.size_limit_bytes).toBe(80 * 1024 * 1024)
@@ -123,7 +204,12 @@ describe('GET /api/reports/full-archive', () => {
)
expect(response.status).toBe(200)
expect(mockLogInfo).toHaveBeenCalledWith('full archive generated', expect.objectContaining({
filename: expect.stringMatching(/^arkiv_full_company-1_\d{8}\.zip$/),
sizeBytes: 1024,
}))
expect(response.headers.get('Content-Type')).toBe('application/zip')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockGenerate).toHaveBeenCalledWith(
expect.anything(),
'company-1',
@@ -172,15 +258,15 @@ describe('GET /api/reports/full-archive', () => {
})
it('returns 400 when scope=period without period_id', async () => {
const { status, body } = await parseJsonResponse(
await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { scope: 'period' },
})
)
const response = await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { scope: 'period' },
}),
)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(body).toEqual({ error: 'period_id is required when scope=period' })
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockGenerate).not.toHaveBeenCalled()
expect(mockEstimate).not.toHaveBeenCalled()
})
@@ -193,14 +279,14 @@ describe('GET /api/reports/full-archive', () => {
})
mockGenerate.mockRejectedValue(new Error('Fiscal period not found'))
const { status, body } = await parseJsonResponse(
await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { scope: 'period', period_id: 'nope' },
})
)
const response = await GET(
createMockRequest('/api/reports/full-archive', {
searchParams: { scope: 'period', period_id: 'nope' },
}),
)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
})
+99 -12
View File
@@ -6,13 +6,22 @@ import {
} from '@/lib/reports/full-archive-export'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
export const runtime = 'nodejs'
export const maxDuration = 300
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
export const GET = withRouteContext('report.full_archive', async (request, { supabase, companyId }) => {
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
export const GET = withRouteContext('report.full_archive', async (request, ctx) => {
const { supabase, companyId, user, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const scopeParam = searchParams.get('scope')
const periodId = searchParams.get('period_id')
@@ -26,26 +35,85 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
if (scope === 'period' && !periodId) {
return NextResponse.json(
{ error: 'period_id is required when scope=period' },
{ status: 400 }
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS }
)
}
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
log.error('failed to authorize full archive export', membershipError, {
userId: user.id,
companyId,
})
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
log.warn('full archive access denied', {
userId: user.id,
companyId,
role: membership?.role ?? null,
})
return privateNoStore(errorResponseFromCode('FORBIDDEN', log, {
requestId,
details: { required_roles: ['owner', 'admin'] },
}))
}
// The complete statutory archive includes exact delivery evidence from all
// company senders. Only this owner/admin server path receives a service-role
// client; normal delivery history remains data-minimized by RLS. companyId
// comes from withRouteContext's authenticated active-company resolution,
// never from a request parameter, and is verified again below.
const archiveClient = createServiceClient()
const { data: verifiedMembership, error: verificationError } = await archiveClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (verificationError) {
log.error('failed to verify full archive export with service role', verificationError, {
userId: user.id,
companyId,
})
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
if (!verifiedMembership || !['owner', 'admin'].includes(verifiedMembership.role)) {
log.warn('full archive service-role verification denied', {
userId: user.id,
companyId,
role: verifiedMembership?.role ?? null,
})
return privateNoStore(errorResponseFromCode('FORBIDDEN', log, {
requestId,
details: { required_roles: ['owner', 'admin'] },
}))
}
try {
const estimate = await estimateArchiveSize(
supabase,
archiveClient,
companyId,
scope,
scope === 'period' ? periodId! : undefined
)
if (estimateOnly) {
return NextResponse.json({
data: {
...estimate,
size_limit_bytes: SIZE_LIMIT_BYTES,
within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES,
return NextResponse.json(
{
data: {
...estimate,
size_limit_bytes: SIZE_LIMIT_BYTES,
within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES,
},
},
})
{ headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (includeDocuments && estimate.total_bytes > SIZE_LIMIT_BYTES) {
@@ -55,12 +123,12 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
size_bytes: estimate.total_bytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
},
{ status: 413 }
{ status: 413, headers: PRIVATE_NO_STORE_HEADERS }
)
}
const zipBuffer = await generateFullArchive(
supabase,
archiveClient,
companyId,
scope === 'period'
? { scope: 'period', period_id: periodId!, include_documents: includeDocuments }
@@ -72,17 +140,36 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
? `arkiv_${periodId}.zip`
: `arkiv_full_${companyId}_${formatDateStamp(new Date())}.zip`
log.info('full archive generated', {
userId: user.id,
companyId,
scope,
includeDocuments,
filename,
sizeBytes: zipBuffer.byteLength,
})
return new NextResponse(zipBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'private, no-store',
},
})
} catch (err) {
log.error('full archive generation failed', err as Error, {
userId: user.id,
companyId,
scope,
includeDocuments,
})
const message = err instanceof Error ? err.message : 'Failed to generate archive'
const status = message.includes('not found') ? 404 : 500
return NextResponse.json({ error: getErrorMessage(err) }, { status })
return NextResponse.json(
{ error: getErrorMessage(err) },
{ status, headers: PRIVATE_NO_STORE_HEADERS },
)
}
})
+112
View File
@@ -97,6 +97,118 @@ describe('PUT /api/settings', () => {
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
})
it('updates invoice email recipients and payment accounts', async () => {
const updates = {
invoice_email_cc_addresses: ['info@example.com', 'owner@example.com'],
invoice_email_bcc_addresses: ['archive@example.com'],
invoice_payment_accounts: {
EUR: {
bank_name: 'Example Bank',
iban: 'SE0022222222222222222222',
bic: 'EXAMSESS',
},
},
}
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'admin' } },
{ data: { id: 's1', ...updates } },
{ data: null, count: 5 },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: updates,
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: typeof updates }>(response)
expect(status).toBe(200)
expect(body.data).toMatchObject(updates)
})
it('rejects fixed invoice recipient changes from a regular member', async () => {
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'member' }, error: null },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { invoice_email_bcc_addresses: ['archive@example.com'] },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { required_roles?: string[] } }
}>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details?.required_roles).toEqual(['owner', 'admin'])
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'company_members',
])
})
it('rejects invoice payment instruction changes from a regular member', async () => {
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'member' }, error: null },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_payment_accounts: {
SEK: { bankgiro: '123-4567' },
},
bankgiro: '123-4567',
},
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { required_roles?: string[] } }
}>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details?.required_roles).toEqual(['owner', 'admin'])
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'company_members',
])
})
it('rejects invalid invoice recipients with otherwise valid payment accounts', async () => {
enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } })
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_email_cc_addresses: ['not-an-email'],
invoice_payment_accounts: {
EUR: { bank_name: 'Example Bank', iban: 'SE0022222222222222222222' },
},
},
}), { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a foreign payment account without IBAN with valid recipients', async () => {
enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } })
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_email_cc_addresses: ['billing@example.com'],
invoice_payment_accounts: { EUR: { bank_name: 'Example Bank' } },
},
}), { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('regenerates deadlines when unchanged tax settings are saved', async () => {
const settings = {
company_id: 'company-1',
+35 -1
View File
@@ -10,6 +10,7 @@ import {
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const GET = withRouteContext(
'settings.get',
@@ -43,7 +44,7 @@ export const GET = withRouteContext(
export const PUT = withRouteContext(
'settings.update',
async (request, { supabase, companyId, log }) => {
async (request, { supabase, companyId, log, requestId, user }) => {
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
@@ -55,6 +56,39 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
const changesInvoiceEmailRecipients =
body.invoice_email_cc_addresses !== undefined
|| body.invoice_email_bcc_addresses !== undefined
const changesInvoicePaymentInstructions =
body.invoice_payment_accounts !== undefined
|| body.bank_name !== undefined
|| body.clearing_number !== undefined
|| body.account_number !== undefined
|| body.bankgiro !== undefined
|| body.plusgiro !== undefined
|| body.swish !== undefined
|| body.iban !== undefined
|| body.bic !== undefined
if (changesInvoiceEmailRecipients || changesInvoicePaymentInstructions) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
log.error('failed to authorize restricted invoice settings', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', log, { requestId })
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return errorResponseFromCode('FORBIDDEN', log, {
requestId,
details: { required_roles: ['owner', 'admin'] },
})
}
}
const reminderDays = [
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
@@ -50,11 +50,13 @@ import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-key
import {
createInvoiceJournalEntry as mockedCreateEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber as mockedEnsureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { POST as markSent } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreateJournalEntry = mockedCreateEntry as ReturnType<typeof vi.fn>
const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
@@ -145,7 +147,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
company_settings: {
data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
},
}),
)
@@ -191,6 +196,65 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
expect(body.error.details.current_status).toBe('sent')
})
it.each([
['missing row', { data: null, error: null }],
['database error', { data: null, error: { message: 'connection reset' } }],
])('fails closed when company settings have a %s', async (_label, settingsResult) => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: settingsResult,
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice without a payment account before number allocation',
async (currency) => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, currency }, error: null },
company_settings: {
data: {
accounting_method: 'accrual',
entity_type: 'enskild_firma',
invoice_payment_accounts: {},
},
error: null,
},
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
},
)
it('rejects delivery notes with VALIDATION_ERROR (regardless of status)', async () => {
// Critical: the delivery-note guard must run BEFORE the status check
// so a sent delivery note still returns 400 (per the documented
@@ -268,7 +332,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
company_settings: {
data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
},
}),
)
// Force the journal-entry generator to throw.
@@ -336,7 +403,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
company_settings: {
data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
},
}),
)
@@ -366,7 +436,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
{ data: DRAFT_INVOICE, error: null },
{ data: SENT_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
company_settings: {
data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
},
}),
)
@@ -42,8 +42,11 @@ import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import {
hasRequiredInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { eventBus } from '@/lib/events'
import type { EntityType, Invoice } from '@/types'
import type { CompanySettings, EntityType, Invoice } from '@/types'
// Explicit projection: drops user_id, company_id (internal scoping).
// default_dimensions must stay in this projection: the fetched row feeds
@@ -204,16 +207,33 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Fetch company settings (accounting method + entity type drive the
// journal-entry decision). Best-effort: without settings we default
// to enskild_firma / accrual which matches the dashboard default.
const { data: settings } = await ctx.supabase
// Fetch company settings before number allocation. Besides the accounting
// decision, payable invoices need a currency-matching account.
const { data: settings, error: settingsError } = await ctx.supabase
.from('company_settings')
.select('accounting_method, entity_type')
.select('accounting_method, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? 'enskild_firma') as EntityType
if (settingsError || !settings) {
if (settingsError) {
ctx.log.error('invoices.mark-sent: company settings fetch failed', settingsError as Error, {
invoiceId,
companyId: ctx.companyId,
})
}
return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, {
requestId: ctx.requestId,
})
}
const companySettings = settings as CompanySettings
if (!hasRequiredInvoicePaymentAccount(companySettings, typed)) {
return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, {
requestId: ctx.requestId,
details: { currency: typed.currency },
})
}
const accountingMethod = companySettings.accounting_method ?? 'accrual'
const entityType = (companySettings.entity_type ?? 'enskild_firma') as EntityType
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
const wouldCreateJournalEntry = isRealInvoice && accountingMethod === 'accrual'
@@ -110,6 +110,7 @@ const COMPANY_SETTINGS = {
company_name: 'Test AB',
entity_type: 'enskild_firma',
accounting_method: 'accrual',
bankgiro: '123-4567',
}
beforeEach(() => {
@@ -142,6 +143,7 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf')
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
@@ -239,6 +241,26 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
expect(body.error.code).toBe('INVOICE_PDF_RENDER_FAILED')
})
it('returns 400 when a foreign payment account is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...SENT_INVOICE, currency: 'EUR' }, error: null },
company_settings: { data: { ...COMPANY_SETTINGS, invoice_payment_accounts: {} }, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockRender).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR for non-UUID id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -26,6 +26,10 @@ import { contentDisposition } from '@/lib/api/content-disposition'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
const INVOICE_PDF_COLUMNS =
@@ -131,6 +135,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
})
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, typed)) {
return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, {
requestId: ctx.requestId,
details: { currency: typed.currency },
})
}
const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Credit-note back-reference per ML 17 kap 22-23§. Best-effort: if the
@@ -153,8 +164,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
typed.currency,
{ paymentAccountRequired: invoiceRequiresPaymentAccount(typed) },
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, typed as Invoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, typed as Invoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: typed as Invoice,
@@ -194,6 +207,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': String(pdfBuffer.length),
'Cache-Control': 'private, no-store',
'X-Request-Id': ctx.requestId,
},
})
@@ -68,6 +68,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
@@ -79,6 +80,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,
@@ -127,10 +129,12 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { ensureInvoiceNumber as mockedEnsureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { POST as sendInvoice } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
@@ -168,13 +172,27 @@ const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(url: string): Request {
function makeRequest(url: string, body?: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
}
function makeRawRequest(url: string, body: string): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab',
},
body,
})
}
function detailParams(companyId: string, id: string) {
@@ -209,6 +227,9 @@ const COMPANY_SETTINGS = {
company_id: COMPANY_ID,
company_name: 'Test AB',
email: 'support@test-ab.example',
invoice_email_cc_addresses: ['fixed-copy@test-ab.example'],
invoice_email_bcc_addresses: ['fixed-archive@test-ab.example'],
bankgiro: '123-4567',
accounting_method: 'accrual',
entity_type: 'enskild_firma',
}
@@ -228,6 +249,154 @@ beforeEach(() => {
})
describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
it('returns 401 without an API key', async () => {
const res = await sendInvoice(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
{ method: 'POST' },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 404 when the invoice does not exist', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: null, error: null },
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('rejects a malformed stored customer email before allocation', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: {
...DRAFT_INVOICE,
customer: { ...DRAFT_INVOICE.customer, email: 'not-an-email' },
},
error: null,
},
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice without a payment account before number allocation',
async (currency) => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...DRAFT_INVOICE, currency }, error: null },
company_settings: {
data: {
...COMPANY_SETTINGS,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
},
error: null,
},
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
},
)
it('returns VALIDATION_ERROR for malformed JSON', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await sendInvoice(
makeRawRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
'{"additional_cc":[',
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it.each([
['invalid additional_cc', { additional_cc: ['not-an-email'] }],
[
'oversized additional_cc',
{ additional_cc: Array.from({ length: 21 }, (_, index) => `copy-${index}@example.test`) },
],
['invalid additional_bcc', { additional_bcc: ['not-an-email'] }],
[
'oversized additional_bcc',
{ additional_bcc: Array.from({ length: 21 }, (_, index) => `archive-${index}@example.test`) },
],
])('returns VALIDATION_ERROR for %s', async (_label, requestBody) => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
}),
)
const res = await sendInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
requestBody,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('sends a draft invoice end-to-end and returns 200 with messageId', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -241,7 +410,13 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
{
additional_cc: ['case-owner@test-ab.example'],
additional_bcc: ['extra-archive@test-ab.example'],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
@@ -251,13 +426,21 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(body.data.invoice_number).toBe('2026-0042')
expect(body.data.message_id).toBe('re_abc123')
expect(body.data.sent_to).toBe('billing@acme.test')
expect(body.data.cc_addresses).toEqual([
'fixed-copy@test-ab.example',
'case-owner@test-ab.example',
])
expect(body.data).not.toHaveProperty('bcc_addresses')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: COMPANY_ID, invoiceId: INVOICE_ID }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
cc: ['fixed-copy@test-ab.example', 'case-owner@test-ab.example'],
bcc: ['fixed-archive@test-ab.example', 'extra-archive@test-ab.example'],
attachments: [
expect.objectContaining({
filename: 'Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf',
@@ -267,6 +450,130 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
)
})
it('rejects custom recipients from a non-admin company member', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'member' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await sendInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
{ additional_bcc: ['external@test-ab.example'] },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('FORBIDDEN')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects a custom recipient collision before allocation', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await sendInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
{ additional_cc: ['BILLING@acme.test'] },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
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 () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: {
data: {
...COMPANY_SETTINGS,
invoice_email_cc_addresses: ['fixed-copy@test-ab.example'],
invoice_email_bcc_addresses: ['fixed-archive@test-ab.example'],
},
error: null,
},
}),
)
const res = await sendInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
{
additional_cc: Array.from(
{ length: 18 },
(_, index) => `additional-${index}@example.test`,
),
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects fixed routing over the total limit without per-send additions', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: {
data: {
...COMPANY_SETTINGS,
invoice_email_cc_addresses: Array.from(
{ length: 19 },
(_, index) => `fixed-${index}@example.test`,
),
invoice_email_bcc_addresses: ['archive@example.test'],
},
error: null,
},
}),
)
const res = await sendInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 503 when email service is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
mockServiceClient.mockReturnValue(
@@ -388,7 +695,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.status).toBe('sent')
expect(body.data.preview.would_send_to).toBe('billing@acme.test')
expect(body.data.preview.would_cc).toBe('support@test-ab.example')
expect(body.data.preview.would_cc).toBe('fixed-copy@test-ab.example')
expect(body.data.preview.would_cc_addresses).toEqual(['fixed-copy@test-ab.example'])
expect(body.data.preview).not.toHaveProperty('would_bcc_addresses')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
expect(body.data.preview.preflight_pdf_render).toBe('ok')
expect(mockSendEmail).not.toHaveBeenCalled()
})
@@ -64,6 +64,18 @@ import {
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import {
EMAIL_PATTERN,
exceedsInvoiceEmailRecipientLimit,
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
findAdditionalInvoiceRecipientCollisions,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { eventBus } from '@/lib/events'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -71,6 +83,21 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
const InvoiceSendBody = z.object({
additional_cc: z.array(z.string().trim().pipe(z.email().max(254)))
.max(MAX_INVOICE_EMAIL_COPY_RECIPIENTS)
.optional(),
additional_bcc: z.array(z.string().trim().pipe(z.email().max(254)))
.max(MAX_INVOICE_EMAIL_COPY_RECIPIENTS)
.optional(),
}).refine(
(data) => (
(data.additional_cc?.length ?? 0) + (data.additional_bcc?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{ path: ['additional_cc'] },
)
const InvoiceSendResponse = z.object({
id: z.string().uuid(),
invoice_number: z.string(),
@@ -78,7 +105,10 @@ const InvoiceSendResponse = z.object({
total: z.number(),
message_id: z.string().nullable(),
sent_to: z.string(),
cc: z.string().nullable(),
cc: z.string().nullable().describe(
'Deprecated compatibility field containing only the first CC recipient. Use cc_addresses for the complete delivery list.',
),
cc_addresses: z.array(z.string()),
journal_entry_id: z.string().uuid().nullable(),
warnings: z
.array(z.object({ code: z.string(), message: z.string() }))
@@ -103,8 +133,15 @@ registerEndpoint({
'A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED): its F-series number is preserved for compliance but the document is not a valid faktura.',
'Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).',
'After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless.',
'additional_cc and additional_bcc require the API key user to be an owner or admin of the company.',
'The deprecated cc response field contains only the first address. Use cc_addresses for the complete CC list.',
'BCC recipients are retained only in the restricted delivery archive and are omitted from normal and dry-run responses.',
],
example: {
request: {
additional_cc: ['case-owner@company.test'],
additional_bcc: ['invoice-archive@company.test'],
},
response: {
data: {
id: '0e9c…',
@@ -114,6 +151,7 @@ registerEndpoint({
message_id: 're_abc123',
sent_to: 'finance@acme.test',
cc: 'billing@gnubok-user.test',
cc_addresses: ['billing@gnubok-user.test'],
journal_entry_id: '7b3a…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
@@ -124,14 +162,28 @@ registerEndpoint({
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: InvoiceSendBody },
response: { success: dataEnvelope(InvoiceSendResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.send',
async (_request, ctx, params) => {
async (request, ctx, params) => {
const { id } = await params.params
let rawBody: unknown = {}
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
}
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -211,6 +263,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
const bodyResult = InvoiceSendBody.safeParse(rawBody)
if (!bodyResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: bodyResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
})
}
// Reject delivery notes: they have a different (D-series) lifecycle.
if (typed.document_type === 'delivery_note') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -253,7 +318,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Step 2: customer email.
const customer = typed.customer
if (!customer?.email) {
if (!customer?.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) {
return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, {
requestId: ctx.requestId,
details: { customer_id: typed.customer_id },
@@ -278,7 +343,71 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
const settings = company as CompanySettings & { accounting_method?: string }
const paymentAccountRequired = invoiceRequiresPaymentAccount(typed)
if (!hasRequiredInvoicePaymentAccount(settings, typed)) {
return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, {
requestId: ctx.requestId,
details: { currency: typed.currency },
})
}
const hasAdditionalRecipients =
(bodyResult.data.additional_cc?.length ?? 0) > 0
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0
// Fixed recipients are owner/admin-approved company routing. A fresh role
// check is required only when this request introduces another recipient.
if (hasAdditionalRecipients) {
const { data: membership, error: membershipError } = await ctx.supabase
.from('company_members')
.select('role')
.eq('company_id', ctx.companyId!)
.eq('user_id', ctx.userId)
.maybeSingle()
if (membershipError) {
ctx.log.error('invoices.send: failed to authorize custom recipients', membershipError)
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
})
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return v1ErrorResponseFromCode('FORBIDDEN', ctx.log, {
requestId: ctx.requestId,
details: { required_roles: ['owner', 'admin'] },
})
}
}
const recipientInput = {
to: customer.email,
configuredCc: settings.invoice_email_cc_addresses,
configuredBcc: settings.invoice_email_bcc_addresses,
// The company email is fixed routing, not an arbitrary
// request-controlled recipient.
legacyCc: settings.email,
additionalCc: bodyResult.data.additional_cc,
additionalBcc: bodyResult.data.additional_bcc,
}
const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput)
if (recipientCollisions.length > 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'recipients', collisions: recipientCollisions },
})
}
const recipients = resolveInvoiceEmailRecipients(recipientInput)
if (recipients.to.length === 0) {
return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, {
requestId: ctx.requestId,
details: { customer_id: typed.customer_id },
})
}
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
return v1ErrorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', ctx.log, {
requestId: ctx.requestId,
details: { recipient_count: invoiceEmailRecipientCount(recipients) },
})
}
const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Credit notes are rejected above, so originalInvoiceNumber is never
// needed on this code path. Kept undefined to satisfy the InvoicePDF
@@ -290,7 +419,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const isFreshAllocation = !typed.invoice_number
if (isFreshAllocation) {
try {
const preflight = await prepareInvoicePdfRender(settings)
const preflight = await prepareInvoicePdfRender(settings, typed.currency, {
paymentAccountRequired,
})
await renderToBuffer(
InvoicePDF({
invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' },
@@ -315,13 +446,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (ctx.dryRun) {
// Dry-run stops here. Validated everything that doesn't have side
// effects; preview the would-be sent state.
return dryRunPreview(
const response = dryRunPreview(
{
...typed,
status: 'sent' as const,
invoice_number: typed.invoice_number ?? '(allocated atomically on commit)',
would_send_to: customer.email,
would_cc: settings.email || null,
would_cc: recipients.cc[0] ?? null,
would_cc_addresses: recipients.cc,
would_create_journal_entry:
(!typed.document_type || typed.document_type === 'invoice') &&
(settings.accounting_method ?? 'accrual') === 'accrual',
@@ -330,6 +462,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
},
{ requestId: ctx.requestId, log: ctx.log },
)
response.headers.set('Cache-Control', 'private, no-store')
return response
}
let deliveryId: string
@@ -419,8 +553,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let pdfBuffer: Buffer
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(settings)
const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
settings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -457,7 +595,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
documentType: typed.document_type,
})
const ccAddress = settings.email ?? null
const emailData = { invoice: renderableInvoice, customer, company: settings }
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
@@ -471,8 +608,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
userId: ctx.userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress ?? undefined,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -658,7 +796,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
companyId: ctx.companyId,
userId: ctx.userId,
invoiceNumber: finalInvoiceNumber,
sentTo: customer.email,
recipientCounts: {
to: recipients.to.length,
cc: recipients.cc.length,
},
journalEntryId,
hadWarnings: warnings.length > 0,
})
@@ -671,11 +812,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
total: typed.total,
message_id: result.messageId ?? null,
sent_to: customer.email,
cc: ccAddress,
cc: recipients.cc[0] ?? null,
cc_addresses: recipients.cc,
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
},
{ requestId: ctx.requestId },
{
requestId: ctx.requestId,
headers: { 'Cache-Control': 'private, no-store' },
},
)
},
{ requireIdempotencyKey: true },