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:
@@ -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