Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
@@ -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 },