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:
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
exceedsInvoiceEmailRecipientLimit,
|
||||
findAdditionalInvoiceRecipientCollisions,
|
||||
invoiceEmailRecipientCount,
|
||||
parseInvoiceRecipientText,
|
||||
resolveInvoiceEmailRecipients,
|
||||
} from '@/lib/invoices/email-recipients'
|
||||
|
||||
describe('resolveInvoiceEmailRecipients', () => {
|
||||
it('uses the legacy copy only while the company list is unconfigured', () => {
|
||||
expect(resolveInvoiceEmailRecipients({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: null,
|
||||
legacyCc: 'billing@example.test',
|
||||
}).cc).toEqual(['billing@example.test'])
|
||||
|
||||
expect(resolveInvoiceEmailRecipients({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: [],
|
||||
legacyCc: 'billing@example.test',
|
||||
}).cc).toEqual([])
|
||||
})
|
||||
|
||||
it('merges fixed and per-send recipients with deterministic precedence', () => {
|
||||
expect(resolveInvoiceEmailRecipients({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: ['finance@example.test', 'CUSTOMER@example.test'],
|
||||
configuredBcc: ['archive@example.test', 'finance@example.test'],
|
||||
additionalCc: ['handler@example.test', 'Finance@example.test'],
|
||||
additionalBcc: ['director@example.test', 'archive@example.test'],
|
||||
})).toEqual({
|
||||
to: ['customer@example.test'],
|
||||
cc: ['finance@example.test', 'handler@example.test'],
|
||||
bcc: ['archive@example.test', 'director@example.test'],
|
||||
})
|
||||
})
|
||||
|
||||
it('counts the final de-duplicated To, CC, and BCC recipients', () => {
|
||||
const atLimit = resolveInvoiceEmailRecipients({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: Array.from({ length: 19 }, (_, index) => `copy-${index}@example.test`),
|
||||
})
|
||||
expect(invoiceEmailRecipientCount(atLimit)).toBe(20)
|
||||
expect(exceedsInvoiceEmailRecipientLimit(atLimit)).toBe(false)
|
||||
|
||||
const overLimit = resolveInvoiceEmailRecipients({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: atLimit.cc,
|
||||
additionalBcc: ['archive@example.test'],
|
||||
})
|
||||
expect(invoiceEmailRecipientCount(overLimit)).toBe(21)
|
||||
expect(exceedsInvoiceEmailRecipientLimit(overLimit)).toBe(true)
|
||||
})
|
||||
|
||||
it('trims and de-duplicates address text', () => {
|
||||
expect(parseInvoiceRecipientText(
|
||||
' finance@example.test,\nDIRECTOR@example.test; finance@example.test ',
|
||||
)).toEqual(['finance@example.test', 'DIRECTOR@example.test'])
|
||||
})
|
||||
|
||||
it('reports per-send collisions instead of silently changing recipient precedence', () => {
|
||||
expect(findAdditionalInvoiceRecipientCollisions({
|
||||
to: 'customer@example.test',
|
||||
configuredCc: ['finance@example.test'],
|
||||
configuredBcc: ['archive@example.test'],
|
||||
additionalCc: ['CUSTOMER@example.test', 'case-owner@example.test'],
|
||||
additionalBcc: ['finance@example.test', 'case-owner@example.test'],
|
||||
})).toEqual([
|
||||
{
|
||||
address: 'CUSTOMER@example.test',
|
||||
field: 'additional_cc',
|
||||
conflicts_with: 'to',
|
||||
},
|
||||
{
|
||||
address: 'finance@example.test',
|
||||
field: 'additional_bcc',
|
||||
conflicts_with: 'configured_cc',
|
||||
},
|
||||
{
|
||||
address: 'case-owner@example.test',
|
||||
field: 'additional_bcc',
|
||||
conflicts_with: 'additional_cc',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,33 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
async function withServiceRoleContext<T>(
|
||||
userId: string,
|
||||
fn: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'service_role' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.role', 'service_role', true)`)
|
||||
await client.query(`SET LOCAL ROLE service_role`)
|
||||
const result = await fn(client)
|
||||
await client.query('ROLLBACK')
|
||||
return result
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function insertInvoice(userId: string, companyId: string): Promise<string> {
|
||||
const customerId = randomUUID()
|
||||
const invoiceId = randomUUID()
|
||||
@@ -68,11 +93,11 @@ async function insertPendingEmailDelivery(params: {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_deliveries
|
||||
(id, user_id, company_id, invoice_id, channel, status,
|
||||
to_addresses, cc_addresses, reply_to, from_name, subject,
|
||||
to_addresses, cc_addresses, bcc_addresses, reply_to, from_name, subject,
|
||||
body_text, body_html, document_attachment_id, attachment_filename,
|
||||
attachment_content_type, attachment_sha256, retention_expires_at)
|
||||
VALUES ($1, $2, $3, $4, 'email', 'pending',
|
||||
ARRAY['customer@example.com'], ARRAY['copy@example.com'],
|
||||
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
|
||||
'sender@example.com', 'Example AB', 'Faktura F-1001',
|
||||
'Exact plain text', '<p>Exact HTML</p>', $5,
|
||||
'invoice.pdf', 'application/pdf', $6, $7)`,
|
||||
@@ -155,6 +180,16 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
[deliveryId],
|
||||
),
|
||||
).rejects.toThrow(/invoice delivery payload is immutable/i)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET status = 'sent', sent_at = now(),
|
||||
bcc_addresses = ARRAY['changed@example.com']
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
),
|
||||
).rejects.toThrow(/invoice delivery payload is immutable/i)
|
||||
})
|
||||
|
||||
it('rejects invoice and document references from another company', async () => {
|
||||
@@ -226,6 +261,119 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
expect(visibleIds).toEqual([deliveryA])
|
||||
})
|
||||
|
||||
it('keeps exact payload sender-only and exposes masked summaries to members', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const memberId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
const deliveryId = await insertPendingEmailDelivery({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId,
|
||||
documentId,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
|
||||
const directRows = await withUserContext(memberId, async (client) => {
|
||||
return client.query(
|
||||
`SELECT id, bcc_addresses, body_text
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
})
|
||||
expect(directRows.rowCount).toBe(0)
|
||||
|
||||
const summary = await withUserContext(memberId, async (client) => {
|
||||
return client.query<Record<string, unknown>>(
|
||||
`SELECT *
|
||||
FROM public.list_invoice_delivery_summaries($1, $2)`,
|
||||
[companyId, invoiceId],
|
||||
)
|
||||
})
|
||||
expect(summary.rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: deliveryId,
|
||||
to_addresses: ['***@example.com'],
|
||||
cc_addresses: ['***@example.com'],
|
||||
}),
|
||||
])
|
||||
expect(summary.rows[0]).not.toHaveProperty('bcc_addresses')
|
||||
expect(summary.rows[0]).not.toHaveProperty('body_text')
|
||||
|
||||
const documentLookup = await withUserContext(memberId, (client) => client.query<{ id: string }>(
|
||||
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
|
||||
[companyId, invoiceId],
|
||||
))
|
||||
expect(documentLookup.rows[0].id).toBe(documentId)
|
||||
|
||||
const other = await seedCompany()
|
||||
const otherInvoiceId = await insertInvoice(other.userId, other.companyId)
|
||||
const otherDocumentId = await insertDocument(other.userId, other.companyId)
|
||||
const otherDeliveryId = await insertPendingEmailDelivery({
|
||||
userId: other.userId,
|
||||
companyId: other.companyId,
|
||||
invoiceId: otherInvoiceId,
|
||||
documentId: otherDocumentId,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
|
||||
[otherDeliveryId],
|
||||
)
|
||||
|
||||
const mismatchedInvoiceLookup = await withUserContext(memberId, (client) =>
|
||||
client.query<{ id: string | null }>(
|
||||
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
|
||||
[companyId, otherInvoiceId],
|
||||
),
|
||||
)
|
||||
expect(mismatchedInvoiceLookup.rows[0].id).toBeNull()
|
||||
await expect(
|
||||
withUserContext(memberId, (client) => client.query(
|
||||
`SELECT public.latest_sent_invoice_delivery_document($1, $2)`,
|
||||
[other.companyId, otherInvoiceId],
|
||||
)),
|
||||
).rejects.toThrow(/not authorized to find delivered invoice document/i)
|
||||
|
||||
await expect(
|
||||
withUserContext(memberId, (client) => client.query(
|
||||
`SELECT id FROM public.export_invoice_delivery_evidence($1)`,
|
||||
[companyId],
|
||||
)),
|
||||
).rejects.toThrow(/owner or admin role required/i)
|
||||
|
||||
const ownerExport = await withUserContext(userId, (client) => client.query<{
|
||||
id: string
|
||||
bcc_addresses: string[]
|
||||
}>(
|
||||
`SELECT id, bcc_addresses
|
||||
FROM public.export_invoice_delivery_evidence($1)
|
||||
WHERE id = $2`,
|
||||
[companyId, deliveryId],
|
||||
))
|
||||
expect(ownerExport.rows[0]).toEqual({
|
||||
id: deliveryId,
|
||||
bcc_addresses: ['archive@example.com'],
|
||||
})
|
||||
|
||||
const senderPayload = await withUserContext(userId, async (client) => {
|
||||
return client.query<{ bcc_addresses: string[]; body_text: string }>(
|
||||
`SELECT bcc_addresses, body_text
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
})
|
||||
expect(senderPayload.rows[0]).toEqual({
|
||||
bcc_addresses: ['archive@example.com'],
|
||||
body_text: 'Exact plain text',
|
||||
})
|
||||
})
|
||||
|
||||
it('denies inserts to a viewer', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const viewerId = await insertAuthUser()
|
||||
@@ -244,6 +392,173 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
).rejects.toThrow(/row-level security|policy/i)
|
||||
})
|
||||
|
||||
it('denies direct delivery writes and RPC execution to authenticated members', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const memberId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
const deliveryId = await insertPendingEmailDelivery({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId,
|
||||
documentId,
|
||||
})
|
||||
|
||||
await expect(
|
||||
withUserContext(memberId, (client) => client.query(
|
||||
`INSERT INTO public.invoice_deliveries
|
||||
(user_id, company_id, invoice_id, channel, status, sent_at)
|
||||
VALUES ($1, $2, $3, 'manual', 'marked_sent', now())`,
|
||||
[memberId, companyId, invoiceId],
|
||||
)),
|
||||
).rejects.toThrow(/row-level security|policy/i)
|
||||
|
||||
const directUpdate = await withUserContext(userId, (client) => client.query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET status = 'sent', sent_at = now()
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
))
|
||||
expect(directUpdate.rowCount).toBe(0)
|
||||
|
||||
await expect(
|
||||
withUserContext(memberId, (client) => client.query(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
|
||||
[companyId, invoiceId, userId],
|
||||
)),
|
||||
).rejects.toThrow(/permission denied/i)
|
||||
})
|
||||
|
||||
it('uses server-only RPCs for reservation, payload capture, and finalization', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
|
||||
await withServiceRoleContext(userId, async (client) => {
|
||||
const reserved = await client.query<{ id: string }>(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
|
||||
[companyId, invoiceId, userId],
|
||||
)
|
||||
const deliveryId = reserved.rows[0].id
|
||||
|
||||
const reused = await client.query<{ id: string }>(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
|
||||
[companyId, invoiceId, userId],
|
||||
)
|
||||
expect(reused.rows[0].id).toBe(deliveryId)
|
||||
|
||||
const captured = await client.query<{ id: string }>(
|
||||
`SELECT public.capture_invoice_delivery_payload(
|
||||
$1, $2, $3, $4,
|
||||
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
|
||||
'sender@example.com', 'Example AB', 'Faktura F-1001',
|
||||
'Exact plain text', '<p>Exact HTML</p>', $5,
|
||||
'invoice.pdf', 'application/pdf', $6
|
||||
)::text AS id`,
|
||||
[deliveryId, companyId, invoiceId, userId, documentId, 'a'.repeat(64)],
|
||||
)
|
||||
expect(captured.rows[0].id).toBe(deliveryId)
|
||||
|
||||
const finalized = await client.query<{ id: string }>(
|
||||
`SELECT public.finalize_invoice_delivery(
|
||||
$1, $2, $3, 'sent', 'resend', 'provider-message-1', NULL
|
||||
)::text AS id`,
|
||||
[deliveryId, companyId, userId],
|
||||
)
|
||||
expect(finalized.rows[0].id).toBe(deliveryId)
|
||||
|
||||
const row = await client.query(
|
||||
`SELECT status, bcc_addresses, body_text
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
expect(row.rows[0]).toMatchObject({
|
||||
status: 'sent',
|
||||
bcc_addresses: ['archive@example.com'],
|
||||
body_text: 'Exact plain text',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects service-role delivery writes for a non-member or mismatched tenant', async () => {
|
||||
const first = await seedCompany()
|
||||
const second = await seedCompany()
|
||||
const outsiderId = await insertAuthUser()
|
||||
const invoiceId = await insertInvoice(first.userId, first.companyId)
|
||||
|
||||
await expect(
|
||||
withServiceRoleContext(outsiderId, (client) => client.query(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
|
||||
[first.companyId, invoiceId, outsiderId],
|
||||
)),
|
||||
).rejects.toThrow(/writable company member/i)
|
||||
|
||||
await expect(
|
||||
withServiceRoleContext(second.userId, (client) => client.query(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
|
||||
[second.companyId, invoiceId, second.userId],
|
||||
)),
|
||||
).rejects.toThrow(/invoice not found/i)
|
||||
})
|
||||
|
||||
it('reclaims only stale payload-free reservations for another sender', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const otherUserId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: otherUserId, role: 'admin' })
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const staleId = randomUUID()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_deliveries
|
||||
(id, user_id, company_id, invoice_id, channel, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'email', 'preparing', now() - interval '16 minutes')`,
|
||||
[staleId, userId, companyId, invoiceId],
|
||||
)
|
||||
|
||||
await withServiceRoleContext(otherUserId, async (client) => {
|
||||
const result = await client.query<{ id: string }>(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
|
||||
[companyId, invoiceId, otherUserId],
|
||||
)
|
||||
expect(result.rows[0].id).not.toBe(staleId)
|
||||
|
||||
const stale = await client.query(
|
||||
`SELECT id FROM public.invoice_deliveries WHERE id = $1`,
|
||||
[staleId],
|
||||
)
|
||||
expect(stale.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('restricts fixed invoice email recipient settings to owners and admins', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const memberId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id)
|
||||
VALUES ($1, $2)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
const memberUpdate = await withUserContext(memberId, (client) => client.query(
|
||||
`UPDATE public.company_settings
|
||||
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
))
|
||||
expect(memberUpdate.rowCount).toBe(0)
|
||||
|
||||
const ownerUpdate = await withUserContext(userId, (client) => client.query(
|
||||
`UPDATE public.company_settings
|
||||
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
))
|
||||
expect(ownerUpdate.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reserves one preparing attempt and promotes it to the exact pending payload', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
@@ -288,7 +603,29 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
[deliveryId],
|
||||
)
|
||||
expect(result.rows[0].status).toBe('pending')
|
||||
expect(result.rows[0].retention_expires_at).toBeTruthy()
|
||||
expect(new Date(result.rows[0].retention_expires_at).toISOString().slice(0, 10)).toBe(
|
||||
'2034-01-01',
|
||||
)
|
||||
|
||||
const nextReservationId = await withServiceRoleContext(userId, async (client) => {
|
||||
const reservation = await client.query<{ id: string }>(
|
||||
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
|
||||
[companyId, invoiceId, userId],
|
||||
)
|
||||
const states = await client.query<{ id: string; status: string }>(
|
||||
`SELECT id, status
|
||||
FROM public.invoice_deliveries
|
||||
WHERE company_id = $1 AND invoice_id = $2
|
||||
ORDER BY created_at`,
|
||||
[companyId, invoiceId],
|
||||
)
|
||||
expect(states.rows).toEqual(expect.arrayContaining([
|
||||
{ id: deliveryId, status: 'pending' },
|
||||
{ id: reservation.rows[0].id, status: 'preparing' },
|
||||
]))
|
||||
return reservation.rows[0].id
|
||||
})
|
||||
expect(nextReservationId).not.toBe(deliveryId)
|
||||
})
|
||||
|
||||
it('allows a failed attempt to release and delete its unsent PDF', async () => {
|
||||
@@ -340,7 +677,7 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
|
||||
|
||||
const delivery = await getPool().query(
|
||||
`SELECT to_addresses, body_text, subject, provider_message_id,
|
||||
`SELECT to_addresses, cc_addresses, bcc_addresses, body_text, subject, provider_message_id,
|
||||
attachment_filename, attachment_sha256, pii_redacted_at
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
@@ -348,6 +685,8 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
)
|
||||
expect(delivery.rows[0]).toMatchObject({
|
||||
to_addresses: [],
|
||||
cc_addresses: [],
|
||||
bcc_addresses: [],
|
||||
body_text: null,
|
||||
subject: null,
|
||||
provider_message_id: null,
|
||||
|
||||
@@ -4,10 +4,14 @@ import type { EmailService } from '@/lib/email/service'
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
const mockDeleteDocument = vi.fn()
|
||||
const mockCreateServiceClient = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
|
||||
}))
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => mockCreateServiceClient(),
|
||||
}))
|
||||
|
||||
import {
|
||||
InvoiceDeliverySnapshotError,
|
||||
@@ -17,63 +21,46 @@ import {
|
||||
} from '../invoice-deliveries'
|
||||
|
||||
function makeSupabase(options?: {
|
||||
insertData?: Record<string, unknown> | null
|
||||
insertError?: { message: string; code?: string } | null
|
||||
existingData?: Record<string, unknown> | null
|
||||
snapshotData?: Record<string, unknown> | null
|
||||
reserveData?: string | null
|
||||
reserveError?: { message: string } | null
|
||||
snapshotData?: string | null
|
||||
snapshotError?: { message: string } | null
|
||||
terminalData?: string | null
|
||||
terminalError?: { message: string } | null
|
||||
manualData?: Record<string, unknown> | null
|
||||
manualError?: { message: string } | null
|
||||
}) {
|
||||
const insertResult = {
|
||||
data: options?.insertData === undefined ? { id: 'delivery-1' } : options.insertData,
|
||||
error: options?.insertError ?? null,
|
||||
}
|
||||
const updateResults = [
|
||||
{
|
||||
data: options?.snapshotData === undefined ? { id: 'delivery-1' } : options.snapshotData,
|
||||
error: options?.snapshotError ?? null,
|
||||
},
|
||||
{ data: null, error: options?.terminalError ?? null },
|
||||
]
|
||||
|
||||
const insertSpy = vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
single: vi.fn().mockResolvedValue(insertResult),
|
||||
})),
|
||||
}))
|
||||
const updateSpy = vi.fn(() => {
|
||||
const result = updateResults.shift() ?? { data: null, error: null }
|
||||
const chain: Record<string, unknown> & {
|
||||
eq: ReturnType<typeof vi.fn>
|
||||
select: ReturnType<typeof vi.fn>
|
||||
single: ReturnType<typeof vi.fn>
|
||||
then: (resolve: (value: typeof result) => void) => void
|
||||
} = {
|
||||
eq: vi.fn(),
|
||||
select: vi.fn(),
|
||||
single: vi.fn().mockResolvedValue(result),
|
||||
then: (resolve) => resolve(result),
|
||||
const rpcSpy = vi.fn((name: string) => {
|
||||
if (name === 'reserve_invoice_delivery') {
|
||||
return Promise.resolve({
|
||||
data: options?.reserveData === undefined ? 'delivery-1' : options.reserveData,
|
||||
error: options?.reserveError ?? null,
|
||||
})
|
||||
}
|
||||
chain.eq.mockReturnValue(chain)
|
||||
chain.select.mockReturnValue(chain)
|
||||
return chain
|
||||
if (name === 'capture_invoice_delivery_payload') {
|
||||
return Promise.resolve({
|
||||
data: options?.snapshotData === undefined ? 'delivery-1' : options.snapshotData,
|
||||
error: options?.snapshotError ?? null,
|
||||
})
|
||||
}
|
||||
if (name === 'finalize_invoice_delivery') {
|
||||
return Promise.resolve({
|
||||
data: options?.terminalData === undefined ? 'delivery-1' : options.terminalData,
|
||||
error: options?.terminalError ?? null,
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
data: options?.manualData === undefined
|
||||
? { id: 'delivery-1', channel: 'manual', status: 'marked_sent' }
|
||||
: options.manualData,
|
||||
error: options?.manualError ?? null,
|
||||
})
|
||||
})
|
||||
const existingResult = { data: options?.existingData ?? null, error: null }
|
||||
const selectChain: Record<string, unknown> & {
|
||||
eq: ReturnType<typeof vi.fn>
|
||||
maybeSingle: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
eq: vi.fn(),
|
||||
maybeSingle: vi.fn().mockResolvedValue(existingResult),
|
||||
}
|
||||
selectChain.eq.mockReturnValue(selectChain)
|
||||
const selectSpy = vi.fn(() => selectChain)
|
||||
const from = vi.fn(() => ({ insert: insertSpy, update: updateSpy, select: selectSpy }))
|
||||
mockCreateServiceClient.mockReturnValue({ rpc: rpcSpy })
|
||||
|
||||
return {
|
||||
supabase: { from } as unknown as SupabaseClient,
|
||||
insertSpy,
|
||||
updateSpy,
|
||||
supabase: {} as SupabaseClient,
|
||||
rpcSpy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +74,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
|
||||
deliveryId: 'delivery-1',
|
||||
to: 'customer@example.com',
|
||||
cc: ['accounting@example.com'],
|
||||
bcc: ['archive@example.com'],
|
||||
replyTo: 'sender@example.com',
|
||||
fromName: 'Example AB',
|
||||
subject: 'Faktura F-1001',
|
||||
@@ -97,7 +85,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeEmailService(sendEmail: ReturnType<typeof vi.fn>): EmailService {
|
||||
function makeEmailService(sendEmail: EmailService['sendEmail']): EmailService {
|
||||
return { isConfigured: () => true, sendEmail }
|
||||
}
|
||||
|
||||
@@ -112,7 +100,7 @@ describe('invoice delivery tracking', () => {
|
||||
})
|
||||
|
||||
it('persists the exact payload before sending and records provider success', async () => {
|
||||
const { supabase, updateSpy } = makeSupabase()
|
||||
const { supabase, rpcSpy } = makeSupabase()
|
||||
const sendEmail = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
provider: 'resend',
|
||||
@@ -123,31 +111,40 @@ describe('invoice delivery tracking', () => {
|
||||
makeInput(supabase, makeEmailService(sendEmail)),
|
||||
)
|
||||
|
||||
expect(updateSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
status: 'pending',
|
||||
to_addresses: ['customer@example.com'],
|
||||
cc_addresses: ['accounting@example.com'],
|
||||
subject: 'Faktura F-1001',
|
||||
body_text: 'Hej!',
|
||||
body_html: '<p>Hej!</p>',
|
||||
document_attachment_id: 'document-1',
|
||||
attachment_filename: 'faktura-f-1001.pdf',
|
||||
attachment_sha256: 'sha256-exact-pdf',
|
||||
}))
|
||||
expect(rpcSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'capture_invoice_delivery_payload',
|
||||
expect.objectContaining({
|
||||
p_to_addresses: ['customer@example.com'],
|
||||
p_cc_addresses: ['accounting@example.com'],
|
||||
p_bcc_addresses: ['archive@example.com'],
|
||||
p_subject: 'Faktura F-1001',
|
||||
p_body_text: 'Hej!',
|
||||
p_body_html: '<p>Hej!</p>',
|
||||
p_document_attachment_id: 'document-1',
|
||||
p_attachment_filename: 'faktura-f-1001.pdf',
|
||||
p_attachment_sha256: 'sha256-exact-pdf',
|
||||
}),
|
||||
)
|
||||
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subject: 'Faktura F-1001',
|
||||
text: 'Hej!',
|
||||
html: '<p>Hej!</p>',
|
||||
bcc: ['archive@example.com'],
|
||||
attachments: [expect.objectContaining({
|
||||
filename: 'faktura-f-1001.pdf',
|
||||
content: Buffer.from('exact-pdf'),
|
||||
})],
|
||||
}))
|
||||
expect(updateSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
status: 'sent',
|
||||
provider: 'resend',
|
||||
provider_message_id: 'provider-message-1',
|
||||
}))
|
||||
expect(rpcSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'finalize_invoice_delivery',
|
||||
expect.objectContaining({
|
||||
p_status: 'sent',
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: 'provider-message-1',
|
||||
}),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
deliveryId: 'delivery-1',
|
||||
@@ -174,7 +171,7 @@ describe('invoice delivery tracking', () => {
|
||||
})
|
||||
|
||||
it('records a failed provider attempt without changing the saved payload', async () => {
|
||||
const { supabase, updateSpy } = makeSupabase()
|
||||
const { supabase, rpcSpy } = makeSupabase()
|
||||
const sendEmail = vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
provider: 'resend',
|
||||
@@ -186,13 +183,15 @@ describe('invoice delivery tracking', () => {
|
||||
makeInput(supabase, makeEmailService(sendEmail)),
|
||||
)
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'failed',
|
||||
provider: 'resend',
|
||||
provider_message_id: null,
|
||||
error_code: 'provider_failed',
|
||||
document_attachment_id: null,
|
||||
}))
|
||||
expect(rpcSpy).toHaveBeenCalledWith(
|
||||
'finalize_invoice_delivery',
|
||||
expect.objectContaining({
|
||||
p_status: 'failed',
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: null,
|
||||
p_error_code: 'provider_failed',
|
||||
}),
|
||||
)
|
||||
expect(mockDeleteDocument).toHaveBeenCalledWith(supabase, 'company-1', 'document-1')
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
@@ -208,11 +207,35 @@ describe('invoice delivery tracking', () => {
|
||||
expect(result.trackingWarning).toBe('finalize_failed')
|
||||
})
|
||||
|
||||
it('reuses an existing preparing reservation after a unique conflict', async () => {
|
||||
it('treats an unexpected terminal delivery id as a finalize failure', async () => {
|
||||
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
|
||||
const sendEmail = vi.fn().mockResolvedValue({ success: true })
|
||||
|
||||
const result = await sendTrackedInvoiceEmail(
|
||||
makeInput(supabase, makeEmailService(sendEmail)),
|
||||
)
|
||||
|
||||
expect(result.trackingWarning).toBe('finalize_failed')
|
||||
})
|
||||
|
||||
it('does not delete the archived PDF when the failed terminal id is unexpected', async () => {
|
||||
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
|
||||
const sendEmail = vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'provider rejected the request',
|
||||
})
|
||||
|
||||
const result = await sendTrackedInvoiceEmail(
|
||||
makeInput(supabase, makeEmailService(sendEmail)),
|
||||
)
|
||||
|
||||
expect(result.trackingWarning).toBe('failure_record_failed')
|
||||
expect(mockDeleteDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the reservation selected by the privileged RPC', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
insertData: null,
|
||||
insertError: { message: 'duplicate', code: '23505' },
|
||||
existingData: { id: 'delivery-existing' },
|
||||
reserveData: 'delivery-existing',
|
||||
})
|
||||
|
||||
await expect(reserveInvoiceDelivery({
|
||||
@@ -229,7 +252,7 @@ describe('invoice delivery tracking', () => {
|
||||
channel: 'manual',
|
||||
status: 'marked_sent',
|
||||
}
|
||||
const { supabase, insertSpy } = makeSupabase({ insertData: manualDelivery })
|
||||
const { supabase, rpcSpy } = makeSupabase({ manualData: manualDelivery })
|
||||
|
||||
await recordManualInvoiceDelivery({
|
||||
supabase,
|
||||
@@ -239,13 +262,11 @@ describe('invoice delivery tracking', () => {
|
||||
sentAt: '2026-07-22T10:30:00.000Z',
|
||||
})
|
||||
|
||||
expect(insertSpy).toHaveBeenCalledWith({
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
invoice_id: 'invoice-1',
|
||||
channel: 'manual',
|
||||
status: 'marked_sent',
|
||||
sent_at: '2026-07-22T10:30:00.000Z',
|
||||
expect(rpcSpy).toHaveBeenCalledWith('record_manual_invoice_delivery', {
|
||||
p_company_id: 'company-1',
|
||||
p_actor_user_id: 'user-1',
|
||||
p_invoice_id: 'invoice-1',
|
||||
p_sent_at: '2026-07-22T10:30:00.000Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
InvoicePaymentAccountMissingError,
|
||||
assertInvoicePaymentAccountForRender,
|
||||
companyWithInvoicePaymentAccount,
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
hasUsableInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
resolveInvoicePaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
function company(overrides: Partial<CompanySettings> = {}): CompanySettings {
|
||||
return {
|
||||
bank_name: 'Legacy bank',
|
||||
clearing_number: '1234',
|
||||
account_number: '1234567',
|
||||
bankgiro: '123-4567',
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: 'SE0011111111111111111111',
|
||||
bic: 'LEGASESS',
|
||||
...overrides,
|
||||
} as CompanySettings
|
||||
}
|
||||
|
||||
describe('invoice payment accounts', () => {
|
||||
it('uses legacy payment details for SEK only', () => {
|
||||
const settings = company()
|
||||
|
||||
expect(resolveInvoicePaymentAccount(settings, 'SEK')?.iban).toBe('SE0011111111111111111111')
|
||||
expect(resolveInvoicePaymentAccount(settings, 'EUR')).toBeNull()
|
||||
})
|
||||
|
||||
it('selects the account matching the invoice currency', () => {
|
||||
const settings = company({
|
||||
invoice_payment_accounts: {
|
||||
EUR: {
|
||||
bank_name: 'EUR bank',
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: 'SE0022222222222222222222',
|
||||
bic: 'EURRSESS',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const rendered = companyWithInvoicePaymentAccount(settings, 'EUR')
|
||||
expect(rendered.bank_name).toBe('EUR bank')
|
||||
expect(rendered.iban).toBe('SE0022222222222222222222')
|
||||
expect(rendered.bankgiro).toBeNull()
|
||||
})
|
||||
|
||||
it('clears legacy SEK details when a foreign account is missing', () => {
|
||||
const rendered = companyWithInvoicePaymentAccount(company(), 'EUR')
|
||||
|
||||
expect(rendered.iban).toBeNull()
|
||||
expect(rendered.bankgiro).toBeNull()
|
||||
})
|
||||
|
||||
it('requires an IBAN for a foreign-currency payment account', () => {
|
||||
const withoutIban = resolveInvoicePaymentAccount(company({
|
||||
invoice_payment_accounts: {
|
||||
EUR: {
|
||||
bank_name: 'EUR bank',
|
||||
clearing_number: '1234',
|
||||
account_number: '1234567',
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
bic: null,
|
||||
},
|
||||
},
|
||||
}), 'EUR')
|
||||
|
||||
expect(hasUsableInvoicePaymentAccount(withoutIban, 'EUR')).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks payable rendering in every currency without a usable account', () => {
|
||||
const emptySettings = company({
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
})
|
||||
|
||||
expect(() => assertInvoicePaymentAccountForRender(company(), 'EUR')).toThrow(
|
||||
InvoicePaymentAccountMissingError,
|
||||
)
|
||||
expect(() => assertInvoicePaymentAccountForRender(emptySettings, 'SEK')).toThrow(
|
||||
InvoicePaymentAccountMissingError,
|
||||
)
|
||||
expect(() => assertInvoicePaymentAccountForRender(company(), 'SEK')).not.toThrow()
|
||||
})
|
||||
|
||||
it('requires payment accounts only for payable invoice documents', () => {
|
||||
expect(invoiceRequiresPaymentAccount(makeInvoice())).toBe(true)
|
||||
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
|
||||
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
|
||||
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts non-payable documents without an account', () => {
|
||||
const emptySettings = company({
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
})
|
||||
|
||||
expect(hasRequiredInvoicePaymentAccount(
|
||||
emptySettings,
|
||||
makeInvoice({ document_type: 'proforma' }),
|
||||
)).toBe(true)
|
||||
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,55 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders only the payment account matching the invoice currency', async () => {
|
||||
const company = makeCompanySettings({
|
||||
iban: 'SE0011111111111111111111',
|
||||
invoice_payment_accounts: {
|
||||
EUR: {
|
||||
bank_name: 'EUR Bank',
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: 'SE0022222222222222222222',
|
||||
bic: 'EURRSESS',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company, 'EUR')
|
||||
|
||||
expect(resolved.bank_name).toBe('EUR Bank')
|
||||
expect(resolved.iban).toBe('SE0022222222222222222222')
|
||||
expect(resolved.bankgiro).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a foreign-currency PDF without a usable payment account', async () => {
|
||||
const company = makeCompanySettings({ invoice_payment_accounts: {} })
|
||||
|
||||
await expect(prepareInvoicePdfRender(company, 'EUR')).rejects.toMatchObject({
|
||||
code: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
||||
currency: 'EUR',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders non-payable foreign documents without requiring an account or leaking SEK details', async () => {
|
||||
const company = makeCompanySettings({
|
||||
iban: 'SE0011111111111111111111',
|
||||
invoice_payment_accounts: {},
|
||||
})
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(
|
||||
company,
|
||||
'EUR',
|
||||
{ paymentAccountRequired: false },
|
||||
)
|
||||
|
||||
expect(resolved.iban).toBeNull()
|
||||
expect(resolved.bankgiro).toBeNull()
|
||||
})
|
||||
|
||||
it('embeds an SVG logo as a PNG data URL so @react-pdf can draw it', async () => {
|
||||
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
|
||||
const company = makeCompanySettings({
|
||||
|
||||
@@ -49,6 +49,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
|
||||
@@ -60,6 +61,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,
|
||||
@@ -220,6 +222,9 @@ describe('executeRecurringSchedule auto-send', () => {
|
||||
const company = makeCompanySettings({
|
||||
company_name: 'Oppy Sverige',
|
||||
accounting_method: 'accrual',
|
||||
bankgiro: '123-4567',
|
||||
invoice_email_cc_addresses: ['fixed-copy@test.se'],
|
||||
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
|
||||
})
|
||||
|
||||
function makeSchedule() {
|
||||
@@ -329,7 +334,12 @@ describe('executeRecurringSchedule auto-send', () => {
|
||||
expect(result.autoSent).toBe(true)
|
||||
expect(result.warning).toBeNull()
|
||||
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
invoiceId: 'inv-1',
|
||||
cc: ['fixed-copy@test.se'],
|
||||
bcc: ['fixed-archive@test.se'],
|
||||
}),
|
||||
)
|
||||
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
|
||||
@@ -368,6 +378,50 @@ describe('executeRecurringSchedule auto-send', () => {
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reserve a delivery when the customer email is blank', async () => {
|
||||
const customerWithoutEmail = { ...customer, email: ' ' }
|
||||
enqueue({ data: customerWithoutEmail, error: null })
|
||||
enqueue({ data: makeInsertedInvoice(), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({
|
||||
data: { ...makeCompleteInvoice(), customer: customerWithoutEmail },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
||||
|
||||
expect(result.autoSent).toBe(false)
|
||||
expect(result.warning).toContain('Auto-utskick misslyckades')
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reserve an auto-send delivery when configured recipients exceed the limit', async () => {
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: makeInsertedInvoice(), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: makeCompleteInvoice(), error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
...company,
|
||||
invoice_email_cc_addresses: Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) => `fixed-${index}@example.test`,
|
||||
),
|
||||
invoice_email_bcc_addresses: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
||||
|
||||
expect(result.autoSent).toBe(false)
|
||||
expect(result.warning).not.toBeNull()
|
||||
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
|
||||
expect(mockRenderToBuffer).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
|
||||
mockIsSandbox.mockResolvedValue(true)
|
||||
// Sandbox bails before company_settings/payment-link/render/email, so the
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
export const MAX_INVOICE_EMAIL_RECIPIENTS = 20
|
||||
export const MAX_INVOICE_EMAIL_COPY_RECIPIENTS = MAX_INVOICE_EMAIL_RECIPIENTS - 1
|
||||
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export interface ResolveInvoiceEmailRecipientsInput {
|
||||
to: string | readonly string[]
|
||||
configuredCc?: readonly string[] | null
|
||||
configuredBcc?: readonly string[] | null
|
||||
legacyCc?: string | null
|
||||
additionalCc?: readonly string[]
|
||||
additionalBcc?: readonly string[]
|
||||
}
|
||||
|
||||
export interface ResolvedInvoiceEmailRecipients {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
}
|
||||
|
||||
export function invoiceEmailRecipientCount(
|
||||
recipients: ResolvedInvoiceEmailRecipients,
|
||||
): number {
|
||||
return recipients.to.length + recipients.cc.length + recipients.bcc.length
|
||||
}
|
||||
|
||||
export function exceedsInvoiceEmailRecipientLimit(
|
||||
recipients: ResolvedInvoiceEmailRecipients,
|
||||
): boolean {
|
||||
return invoiceEmailRecipientCount(recipients) > MAX_INVOICE_EMAIL_RECIPIENTS
|
||||
}
|
||||
|
||||
export interface InvoiceEmailRecipientCollision {
|
||||
address: string
|
||||
field: 'additional_cc' | 'additional_bcc'
|
||||
conflicts_with:
|
||||
| 'to'
|
||||
| 'configured_cc'
|
||||
| 'configured_bcc'
|
||||
| 'additional_cc'
|
||||
| 'additional_bcc'
|
||||
}
|
||||
|
||||
function normalizedKey(address: string): string {
|
||||
return address.trim().toLocaleLowerCase('en-US')
|
||||
}
|
||||
|
||||
function uniqueAddresses(
|
||||
addresses: readonly string[],
|
||||
used: Set<string>,
|
||||
): string[] {
|
||||
const result: string[] = []
|
||||
|
||||
for (const rawAddress of addresses) {
|
||||
const address = rawAddress.trim()
|
||||
const key = normalizedKey(address)
|
||||
if (!key || used.has(key)) continue
|
||||
used.add(key)
|
||||
result.push(address)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact recipient lists submitted to the email provider.
|
||||
*
|
||||
* A null company CC list means the company has never configured the new
|
||||
* setting, so the historical automatic-copy address remains in effect. An
|
||||
* explicit empty list disables that fallback. Recipients are de-duplicated
|
||||
* with To taking precedence over CC and CC taking precedence over BCC.
|
||||
*/
|
||||
export function resolveInvoiceEmailRecipients(
|
||||
input: ResolveInvoiceEmailRecipientsInput,
|
||||
): ResolvedInvoiceEmailRecipients {
|
||||
const used = new Set<string>()
|
||||
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
|
||||
const to = uniqueAddresses(rawTo, used)
|
||||
|
||||
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
|
||||
? input.legacyCc
|
||||
? [input.legacyCc]
|
||||
: []
|
||||
: input.configuredCc
|
||||
|
||||
const cc = uniqueAddresses(
|
||||
[...fixedCc, ...(input.additionalCc ?? [])],
|
||||
used,
|
||||
)
|
||||
const bcc = uniqueAddresses(
|
||||
[...(input.configuredBcc ?? []), ...(input.additionalBcc ?? [])],
|
||||
used,
|
||||
)
|
||||
|
||||
return { to, cc, bcc }
|
||||
}
|
||||
|
||||
/**
|
||||
* Report explicit per-send recipients that would be silently moved or omitted
|
||||
* by deterministic To, CC, BCC precedence. Company-level configuration keeps
|
||||
* its historical de-duplication behavior, while caller-supplied collisions are
|
||||
* rejected before invoice number allocation so the caller can correct them.
|
||||
*/
|
||||
export function findAdditionalInvoiceRecipientCollisions(
|
||||
input: ResolveInvoiceEmailRecipientsInput,
|
||||
): InvoiceEmailRecipientCollision[] {
|
||||
const occupied = new Map<string, InvoiceEmailRecipientCollision['conflicts_with']>()
|
||||
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
|
||||
for (const address of rawTo) {
|
||||
const key = normalizedKey(address)
|
||||
if (key) occupied.set(key, 'to')
|
||||
}
|
||||
|
||||
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
|
||||
? input.legacyCc
|
||||
? [input.legacyCc]
|
||||
: []
|
||||
: input.configuredCc
|
||||
for (const address of fixedCc) {
|
||||
const key = normalizedKey(address)
|
||||
if (key && !occupied.has(key)) occupied.set(key, 'configured_cc')
|
||||
}
|
||||
for (const address of input.configuredBcc ?? []) {
|
||||
const key = normalizedKey(address)
|
||||
if (key && !occupied.has(key)) occupied.set(key, 'configured_bcc')
|
||||
}
|
||||
|
||||
const collisions: InvoiceEmailRecipientCollision[] = []
|
||||
for (const address of input.additionalCc ?? []) {
|
||||
const key = normalizedKey(address)
|
||||
if (!key) continue
|
||||
const conflict = occupied.get(key)
|
||||
if (conflict) {
|
||||
collisions.push({ address: address.trim(), field: 'additional_cc', conflicts_with: conflict })
|
||||
continue
|
||||
}
|
||||
occupied.set(key, 'additional_cc')
|
||||
}
|
||||
for (const address of input.additionalBcc ?? []) {
|
||||
const key = normalizedKey(address)
|
||||
if (!key) continue
|
||||
const conflict = occupied.get(key)
|
||||
if (conflict) {
|
||||
collisions.push({ address: address.trim(), field: 'additional_bcc', conflicts_with: conflict })
|
||||
continue
|
||||
}
|
||||
occupied.set(key, 'additional_bcc')
|
||||
}
|
||||
|
||||
return collisions
|
||||
}
|
||||
|
||||
export function parseInvoiceRecipientText(value: string): string[] {
|
||||
const used = new Set<string>()
|
||||
return uniqueAddresses(value.split(/[\n,;]+/), used)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
|
||||
import { deleteDocument, uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import type { InvoiceDelivery } from '@/types'
|
||||
|
||||
const PDF_CONTENT_TYPE = 'application/pdf'
|
||||
@@ -21,6 +22,7 @@ export interface TrackedInvoiceEmailInput {
|
||||
deliveryId: string
|
||||
to: string | string[]
|
||||
cc?: string | string[]
|
||||
bcc?: string | string[]
|
||||
replyTo?: string
|
||||
fromName?: string
|
||||
subject: string
|
||||
@@ -44,6 +46,9 @@ function addresses(value?: string | string[]): string[] {
|
||||
/**
|
||||
* Persist a reusable delivery attempt before allocating an invoice number.
|
||||
* The unique preparing row is also the concurrency lock for one invoice send.
|
||||
* The stateless service-role client is only transport for the service-only RPC:
|
||||
* the RPC re-authorizes userId as a writable member of companyId and scopes the
|
||||
* invoice row to the same company before it can write anything.
|
||||
*/
|
||||
export async function reserveInvoiceDelivery(args: {
|
||||
supabase: SupabaseClient
|
||||
@@ -51,31 +56,13 @@ export async function reserveInvoiceDelivery(args: {
|
||||
userId: string
|
||||
invoiceId: string
|
||||
}): Promise<string> {
|
||||
const { data, error } = await args.supabase
|
||||
.from('invoice_deliveries')
|
||||
.insert({
|
||||
company_id: args.companyId,
|
||||
user_id: args.userId,
|
||||
invoice_id: args.invoiceId,
|
||||
channel: 'email',
|
||||
status: 'preparing',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
const { data, error } = await createServiceClient().rpc('reserve_invoice_delivery', {
|
||||
p_company_id: args.companyId,
|
||||
p_invoice_id: args.invoiceId,
|
||||
p_actor_user_id: args.userId,
|
||||
})
|
||||
|
||||
if (data?.id) return data.id
|
||||
|
||||
if ((error as { code?: string } | null)?.code === '23505') {
|
||||
const { data: existing, error: existingError } = await args.supabase
|
||||
.from('invoice_deliveries')
|
||||
.select('id')
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('invoice_id', args.invoiceId)
|
||||
.eq('status', 'preparing')
|
||||
.maybeSingle()
|
||||
|
||||
if (!existingError && existing?.id) return existing.id
|
||||
}
|
||||
if (!error && typeof data === 'string') return data
|
||||
|
||||
throw new InvoiceDeliverySnapshotError(
|
||||
`Failed to reserve invoice delivery: ${error?.message || 'unknown error'}`,
|
||||
@@ -94,6 +81,7 @@ export async function sendTrackedInvoiceEmail(
|
||||
deliveryId,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
replyTo,
|
||||
fromName,
|
||||
subject,
|
||||
@@ -116,30 +104,33 @@ export async function sendTrackedInvoiceEmail(
|
||||
{ upload_source: 'system' },
|
||||
)
|
||||
|
||||
const { data: delivery, error: deliveryError } = await supabase
|
||||
.from('invoice_deliveries')
|
||||
.update({
|
||||
status: 'pending',
|
||||
to_addresses: addresses(to),
|
||||
cc_addresses: addresses(cc),
|
||||
reply_to: replyTo || null,
|
||||
from_name: fromName || null,
|
||||
subject,
|
||||
body_text: text,
|
||||
body_html: html,
|
||||
document_attachment_id: document.id,
|
||||
attachment_filename: filename,
|
||||
attachment_content_type: PDF_CONTENT_TYPE,
|
||||
attachment_sha256: document.sha256_hash,
|
||||
})
|
||||
.eq('id', deliveryId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('invoice_id', invoiceId)
|
||||
.eq('status', 'preparing')
|
||||
.select('*')
|
||||
.single()
|
||||
// These service-only RPCs re-authorize userId against companyId and bind
|
||||
// every transition to the reserved invoice, so no caller-supplied tenant or
|
||||
// actor identifier is trusted merely because this client bypasses RLS.
|
||||
const deliveryWriter = createServiceClient()
|
||||
const { data: capturedDeliveryId, error: deliveryError } = await deliveryWriter.rpc(
|
||||
'capture_invoice_delivery_payload',
|
||||
{
|
||||
p_delivery_id: deliveryId,
|
||||
p_company_id: companyId,
|
||||
p_invoice_id: invoiceId,
|
||||
p_actor_user_id: userId,
|
||||
p_to_addresses: addresses(to),
|
||||
p_cc_addresses: addresses(cc),
|
||||
p_bcc_addresses: addresses(bcc),
|
||||
p_reply_to: replyTo || null,
|
||||
p_from_name: fromName || null,
|
||||
p_subject: subject,
|
||||
p_body_text: text,
|
||||
p_body_html: html,
|
||||
p_document_attachment_id: document.id,
|
||||
p_attachment_filename: filename,
|
||||
p_attachment_content_type: PDF_CONTENT_TYPE,
|
||||
p_attachment_sha256: document.sha256_hash,
|
||||
},
|
||||
)
|
||||
|
||||
if (deliveryError || !delivery) {
|
||||
if (deliveryError || capturedDeliveryId !== deliveryId) {
|
||||
try {
|
||||
await deleteDocument(supabase, companyId, document.id)
|
||||
} catch {
|
||||
@@ -154,6 +145,7 @@ export async function sendTrackedInvoiceEmail(
|
||||
const emailOptions: SendEmailOptions = {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
@@ -170,22 +162,22 @@ export async function sendTrackedInvoiceEmail(
|
||||
const result = await emailService.sendEmail(emailOptions)
|
||||
|
||||
if (!result.success) {
|
||||
const { error: failureRecordError } = await supabase
|
||||
.from('invoice_deliveries')
|
||||
.update({
|
||||
status: 'failed',
|
||||
provider: result.provider || null,
|
||||
provider_message_id: null,
|
||||
error_code: 'provider_failed',
|
||||
document_attachment_id: null,
|
||||
failed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', delivery.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
const { data: failedDeliveryId, error: failureRecordError } = await deliveryWriter.rpc(
|
||||
'finalize_invoice_delivery',
|
||||
{
|
||||
p_delivery_id: deliveryId,
|
||||
p_company_id: companyId,
|
||||
p_actor_user_id: userId,
|
||||
p_status: 'failed',
|
||||
p_provider: result.provider || null,
|
||||
p_provider_message_id: null,
|
||||
p_error_code: 'provider_failed',
|
||||
},
|
||||
)
|
||||
|
||||
const failureRecorded = !failureRecordError && failedDeliveryId === deliveryId
|
||||
let cleanupFailed = false
|
||||
if (!failureRecordError) {
|
||||
if (failureRecorded) {
|
||||
try {
|
||||
const cleanup = await deleteDocument(supabase, companyId, document.id)
|
||||
cleanupFailed = !cleanup.ok
|
||||
@@ -196,9 +188,9 @@ export async function sendTrackedInvoiceEmail(
|
||||
|
||||
return {
|
||||
...result,
|
||||
deliveryId: delivery.id,
|
||||
deliveryId,
|
||||
documentId: document.id,
|
||||
...(failureRecordError
|
||||
...(!failureRecorded
|
||||
? { trackingWarning: 'failure_record_failed' as const }
|
||||
: cleanupFailed
|
||||
? { trackingWarning: 'failure_cleanup_failed' as const }
|
||||
@@ -206,23 +198,31 @@ export async function sendTrackedInvoiceEmail(
|
||||
}
|
||||
}
|
||||
|
||||
const { error: finalizeError } = await supabase
|
||||
.from('invoice_deliveries')
|
||||
.update({
|
||||
status: 'sent',
|
||||
provider: result.provider || null,
|
||||
provider_message_id: result.messageId || null,
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', delivery.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
const { data: finalizedDeliveryId, error: finalizeError } = await deliveryWriter.rpc(
|
||||
'finalize_invoice_delivery',
|
||||
{
|
||||
p_delivery_id: deliveryId,
|
||||
p_company_id: companyId,
|
||||
p_actor_user_id: userId,
|
||||
p_status: 'sent',
|
||||
p_provider: result.provider || null,
|
||||
p_provider_message_id: result.messageId || null,
|
||||
p_error_code: null,
|
||||
},
|
||||
)
|
||||
|
||||
// Delivery is irreversible once the provider succeeds. A failed terminal
|
||||
// transition is returned as a reconciliation warning; each caller still
|
||||
// advances the invoice to sent, and ordinary send routes reject non-drafts,
|
||||
// so a pending evidence row never becomes permission to send a duplicate.
|
||||
// Pending rows are outside the preparing-only reservation lock, so retained
|
||||
// evidence also cannot block a later explicitly authorized resend.
|
||||
const finalized = !finalizeError && finalizedDeliveryId === deliveryId
|
||||
return {
|
||||
...result,
|
||||
deliveryId: delivery.id,
|
||||
deliveryId,
|
||||
documentId: document.id,
|
||||
...(finalizeError ? { trackingWarning: 'finalize_failed' as const } : {}),
|
||||
...(!finalized ? { trackingWarning: 'finalize_failed' as const } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,18 +233,12 @@ export async function recordManualInvoiceDelivery(args: {
|
||||
invoiceId: string
|
||||
sentAt?: string
|
||||
}): Promise<InvoiceDelivery> {
|
||||
const { data, error } = await args.supabase
|
||||
.from('invoice_deliveries')
|
||||
.insert({
|
||||
company_id: args.companyId,
|
||||
user_id: args.userId,
|
||||
invoice_id: args.invoiceId,
|
||||
channel: 'manual',
|
||||
status: 'marked_sent',
|
||||
sent_at: args.sentAt || new Date().toISOString(),
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
const { data, error } = await createServiceClient().rpc('record_manual_invoice_delivery', {
|
||||
p_company_id: args.companyId,
|
||||
p_invoice_id: args.invoiceId,
|
||||
p_actor_user_id: args.userId,
|
||||
p_sent_at: args.sentAt || null,
|
||||
})
|
||||
|
||||
if (error || !data) {
|
||||
throw new InvoiceDeliverySnapshotError(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
CompanySettings,
|
||||
Currency,
|
||||
Invoice,
|
||||
InvoicePaymentAccount,
|
||||
} from '@/types'
|
||||
|
||||
export const INVOICE_PAYMENT_ACCOUNT_CURRENCIES: readonly Currency[] = [
|
||||
'SEK',
|
||||
'EUR',
|
||||
'USD',
|
||||
'GBP',
|
||||
'NOK',
|
||||
'DKK',
|
||||
]
|
||||
|
||||
const PAYMENT_FIELDS: readonly (keyof InvoicePaymentAccount)[] = [
|
||||
'bank_name',
|
||||
'clearing_number',
|
||||
'account_number',
|
||||
'bankgiro',
|
||||
'plusgiro',
|
||||
'swish',
|
||||
'iban',
|
||||
'bic',
|
||||
]
|
||||
|
||||
function clean(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
export function legacySekInvoicePaymentAccount(
|
||||
company: Pick<CompanySettings, keyof InvoicePaymentAccount>,
|
||||
): InvoicePaymentAccount {
|
||||
return {
|
||||
bank_name: clean(company.bank_name),
|
||||
clearing_number: clean(company.clearing_number),
|
||||
account_number: clean(company.account_number),
|
||||
bankgiro: clean(company.bankgiro),
|
||||
plusgiro: clean(company.plusgiro),
|
||||
swish: clean(company.swish),
|
||||
iban: clean(company.iban),
|
||||
bic: clean(company.bic),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeInvoicePaymentAccount(
|
||||
account: Partial<InvoicePaymentAccount>,
|
||||
): InvoicePaymentAccount {
|
||||
return {
|
||||
bank_name: clean(account.bank_name),
|
||||
clearing_number: clean(account.clearing_number),
|
||||
account_number: clean(account.account_number),
|
||||
bankgiro: clean(account.bankgiro),
|
||||
plusgiro: clean(account.plusgiro),
|
||||
swish: clean(account.swish),
|
||||
iban: clean(account.iban)?.replace(/\s/g, '').toUpperCase() ?? null,
|
||||
bic: clean(account.bic)?.replace(/\s/g, '').toUpperCase() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInvoicePaymentAccount(
|
||||
company: CompanySettings,
|
||||
currency: Currency,
|
||||
): InvoicePaymentAccount | null {
|
||||
const configured = company.invoice_payment_accounts?.[currency]
|
||||
if (configured) return normalizeInvoicePaymentAccount(configured)
|
||||
return currency === 'SEK' ? legacySekInvoicePaymentAccount(company) : null
|
||||
}
|
||||
|
||||
export function hasUsableInvoicePaymentAccount(
|
||||
account: InvoicePaymentAccount | null,
|
||||
currency: Currency,
|
||||
): boolean {
|
||||
if (!account) return false
|
||||
if (currency !== 'SEK') return !!account.iban
|
||||
return !!(
|
||||
account.iban
|
||||
|| account.bankgiro
|
||||
|| account.plusgiro
|
||||
|| account.swish
|
||||
|| (account.clearing_number && account.account_number)
|
||||
)
|
||||
}
|
||||
|
||||
export function invoiceRequiresPaymentAccount(
|
||||
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
|
||||
): boolean {
|
||||
return !invoice.credited_invoice_id
|
||||
&& invoice.document_type !== 'delivery_note'
|
||||
&& invoice.document_type !== 'proforma'
|
||||
}
|
||||
|
||||
export function hasRequiredInvoicePaymentAccount(
|
||||
company: CompanySettings,
|
||||
invoice: Pick<Invoice, 'credited_invoice_id' | 'currency' | 'document_type'>,
|
||||
): boolean {
|
||||
return !invoiceRequiresPaymentAccount(invoice)
|
||||
|| hasUsableInvoicePaymentAccount(
|
||||
resolveInvoicePaymentAccount(company, invoice.currency),
|
||||
invoice.currency,
|
||||
)
|
||||
}
|
||||
|
||||
export class InvoicePaymentAccountMissingError extends Error {
|
||||
readonly code = 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING'
|
||||
readonly currency: Currency
|
||||
|
||||
constructor(currency: Currency) {
|
||||
super(`Invoice payment account is missing for ${currency}.`)
|
||||
this.name = 'InvoicePaymentAccountMissingError'
|
||||
this.currency = currency
|
||||
}
|
||||
}
|
||||
|
||||
export function assertInvoicePaymentAccountForRender(
|
||||
company: CompanySettings,
|
||||
currency: Currency,
|
||||
): void {
|
||||
if (
|
||||
!hasUsableInvoicePaymentAccount(
|
||||
resolveInvoicePaymentAccount(company, currency),
|
||||
currency,
|
||||
)
|
||||
) {
|
||||
throw new InvoicePaymentAccountMissingError(currency)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return invoice render settings with only the matching payment account.
|
||||
* Foreign invoices never inherit the legacy SEK payment details.
|
||||
*/
|
||||
export function companyWithInvoicePaymentAccount(
|
||||
company: CompanySettings,
|
||||
currency: Currency,
|
||||
): CompanySettings {
|
||||
const account = resolveInvoicePaymentAccount(company, currency)
|
||||
const updates = Object.fromEntries(
|
||||
PAYMENT_FIELDS.map((field) => [field, account?.[field] ?? null]),
|
||||
) as Pick<CompanySettings, keyof InvoicePaymentAccount>
|
||||
return { ...company, ...updates }
|
||||
}
|
||||
@@ -20,13 +20,17 @@
|
||||
*/
|
||||
|
||||
import QRCode from 'qrcode'
|
||||
import type { CompanySettings, Invoice } from '@/types'
|
||||
import type { CompanySettings, Currency, Invoice } from '@/types'
|
||||
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
|
||||
import { buildSwishQrPayload } from '@/lib/payments/swish'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
|
||||
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
|
||||
import {
|
||||
assertInvoicePaymentAccountForRender,
|
||||
companyWithInvoicePaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
|
||||
const log = createLogger('invoice.swish-qr')
|
||||
const paymentLinkLog = createLogger('invoice.payment-link-qr')
|
||||
@@ -42,6 +46,10 @@ export interface InvoicePdfRenderExtras {
|
||||
company: CompanySettings
|
||||
}
|
||||
|
||||
export interface InvoicePdfRenderOptions {
|
||||
paymentAccountRequired?: boolean
|
||||
}
|
||||
|
||||
// A company's logo is reused across every invoice render, and twice per send
|
||||
// (preflight + final render), and once per invoice in recurring/batch loops:
|
||||
// so cache the re-encoded result keyed by logo URL. Only successes are cached
|
||||
@@ -144,18 +152,26 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
|
||||
|
||||
export async function prepareInvoicePdfRender(
|
||||
company: CompanySettings,
|
||||
currency?: Currency,
|
||||
options: InvoicePdfRenderOptions = {},
|
||||
): Promise<InvoicePdfRenderExtras> {
|
||||
if (currency && options.paymentAccountRequired !== false) {
|
||||
assertInvoicePaymentAccountForRender(company, currency)
|
||||
}
|
||||
const branding = await prepareInvoiceFont(
|
||||
company,
|
||||
brandingFromCompanySettings(company),
|
||||
)
|
||||
if (!company.logo_url) return { branding, company }
|
||||
const paymentCompany = currency
|
||||
? companyWithInvoicePaymentAccount(company, currency)
|
||||
: company
|
||||
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
|
||||
|
||||
const dataUrl = await resolveLogoDataUrl(company.logo_url)
|
||||
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
|
||||
const resolved =
|
||||
dataUrl && dataUrl !== company.logo_url
|
||||
? { ...company, logo_url: dataUrl }
|
||||
: company
|
||||
dataUrl && dataUrl !== paymentCompany.logo_url
|
||||
? { ...paymentCompany, logo_url: dataUrl }
|
||||
: paymentCompany
|
||||
return { branding, company: resolved }
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ import {
|
||||
reserveInvoiceDelivery,
|
||||
sendTrackedInvoiceEmail,
|
||||
} from '@/lib/invoices/invoice-deliveries'
|
||||
import {
|
||||
exceedsInvoiceEmailRecipientLimit,
|
||||
invoiceEmailRecipientCount,
|
||||
resolveInvoiceEmailRecipients,
|
||||
} from '@/lib/invoices/email-recipients'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type {
|
||||
Invoice,
|
||||
@@ -439,7 +447,7 @@ async function sendInvoiceFromSchedule(
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!invoice.customer.email) {
|
||||
if (!invoice.customer.email?.trim()) {
|
||||
log.warn('customer has no email; recurring schedule cannot auto-send', {
|
||||
invoiceId: invoice.id,
|
||||
customerId: invoice.customer.id,
|
||||
@@ -456,7 +464,26 @@ async function sendInvoiceFromSchedule(
|
||||
if (!company) {
|
||||
throw new Error('company settings missing: cannot send invoice')
|
||||
}
|
||||
|
||||
if (!hasRequiredInvoicePaymentAccount(company, invoice)) {
|
||||
log.warn('invoice currency has no usable payment account; recurring schedule cannot auto-send', {
|
||||
invoiceId: invoice.id,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
return false
|
||||
}
|
||||
const recipients = resolveInvoiceEmailRecipients({
|
||||
to: invoice.customer.email,
|
||||
configuredCc: company.invoice_email_cc_addresses,
|
||||
configuredBcc: company.invoice_email_bcc_addresses,
|
||||
legacyCc: company.email,
|
||||
})
|
||||
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
|
||||
log.warn('invoice has too many email recipients; recurring schedule cannot auto-send', {
|
||||
invoiceId: invoice.id,
|
||||
recipientCount: invoiceEmailRecipientCount(recipients),
|
||||
})
|
||||
return false
|
||||
}
|
||||
let deliveryId: string
|
||||
try {
|
||||
deliveryId = await reserveInvoiceDelivery({
|
||||
@@ -498,8 +525,11 @@ async function sendInvoiceFromSchedule(
|
||||
// Render PDF with status overridden to 'sent' so the customer doesn't
|
||||
// receive a "UTKAST" stamp.
|
||||
const renderableInvoice = { ...invoice, status: 'sent' as const }
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(company)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
company,
|
||||
renderableInvoice.currency,
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
@@ -522,8 +552,6 @@ async function sendInvoiceFromSchedule(
|
||||
invoiceDate: invoice.invoice_date,
|
||||
documentType: invoice.document_type,
|
||||
})
|
||||
const ccAddress = company.email || undefined
|
||||
|
||||
const subject = generateInvoiceEmailSubject(emailData)
|
||||
const html = generateInvoiceEmailHtml(emailData)
|
||||
const text = generateInvoiceEmailText(emailData)
|
||||
@@ -536,8 +564,9 @@ async function sendInvoiceFromSchedule(
|
||||
userId,
|
||||
invoiceId: invoice.id,
|
||||
deliveryId,
|
||||
to: invoice.customer.email,
|
||||
cc: ccAddress,
|
||||
to: recipients.to,
|
||||
cc: recipients.cc,
|
||||
bcc: recipients.bcc,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
|
||||
Reference in New Issue
Block a user