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