* 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
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
computeNextRunDate,
|
|
computeInitialRunDate,
|
|
getStockholmDateHour,
|
|
executeRecurringSchedule,
|
|
} from '@/lib/invoices/recurring-schedule-service'
|
|
import { createQueuedMockSupabase, makeCustomer, makeCompanySettings } from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
// ── Mocks for the executeRecurringSchedule auto-send path ─────────────
|
|
// The pure date-helper tests below don't touch any of these modules.
|
|
|
|
const mockRenderToBuffer = vi.fn()
|
|
vi.mock('@react-pdf/renderer', () => ({
|
|
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
|
}))
|
|
|
|
const mockInvoicePDF = vi.fn()
|
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
|
InvoicePDF: (...args: unknown[]) => mockInvoicePDF(...args),
|
|
}))
|
|
|
|
const mockPrepareRender = vi.fn()
|
|
const mockSwishQr = vi.fn()
|
|
const mockPaymentLinkQr = vi.fn()
|
|
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
|
|
prepareInvoicePdfRender: (...args: unknown[]) => mockPrepareRender(...args),
|
|
buildSwishQrDataUrl: (...args: unknown[]) => mockSwishQr(...args),
|
|
buildPaymentLinkQrDataUrl: (...args: unknown[]) => mockPaymentLinkQr(...args),
|
|
}))
|
|
|
|
const mockApplyPaymentLink = vi.fn()
|
|
vi.mock('@/lib/extensions/payment-links', () => ({
|
|
applyPaymentLinkToInvoice: (...args: unknown[]) => mockApplyPaymentLink(...args),
|
|
}))
|
|
|
|
const mockSendEmail = vi.fn()
|
|
const mockIsConfigured = vi.fn()
|
|
vi.mock('@/lib/email/service', () => ({
|
|
getEmailService: () => ({
|
|
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
|
isConfigured: () => mockIsConfigured(),
|
|
}),
|
|
}))
|
|
|
|
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
|
|
replyTo?: string
|
|
fromName?: string
|
|
filename: string
|
|
pdfBuffer: Buffer
|
|
}) => ({
|
|
...(await input.emailService.sendEmail({
|
|
to: input.to,
|
|
cc: input.cc,
|
|
bcc: input.bcc,
|
|
subject: input.subject,
|
|
html: input.html,
|
|
text: input.text,
|
|
replyTo: input.replyTo,
|
|
fromName: input.fromName,
|
|
attachments: [{
|
|
filename: input.filename,
|
|
content: input.pdfBuffer,
|
|
contentType: 'application/pdf',
|
|
}],
|
|
})),
|
|
deliveryId: 'delivery-1',
|
|
documentId: 'document-1',
|
|
}))
|
|
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
|
|
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
|
|
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
|
|
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
|
|
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
|
|
}))
|
|
|
|
vi.mock('@/lib/email/invoice-templates', () => ({
|
|
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
|
|
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
|
|
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-1'),
|
|
}))
|
|
|
|
const mockIsSandbox = vi.fn()
|
|
vi.mock('@/lib/sandbox/guard', () => ({
|
|
isSandboxCompany: (...args: unknown[]) => mockIsSandbox(...args),
|
|
}))
|
|
|
|
const mockHasCapability = vi.fn()
|
|
vi.mock('@/lib/entitlements/has-capability', () => ({
|
|
hasCapability: (...args: unknown[]) => mockHasCapability(...args),
|
|
}))
|
|
|
|
const mockEnsureNumber = vi.fn()
|
|
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
|
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureNumber(...args),
|
|
}))
|
|
|
|
const mockCreateJE = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
|
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
|
|
}))
|
|
|
|
const mockUploadDocument = vi.fn()
|
|
vi.mock('@/lib/core/documents/document-service', () => ({
|
|
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
|
linkToJournalEntry: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
describe('computeNextRunDate', () => {
|
|
it('advances day 15 from January to February', () => {
|
|
const result = computeNextRunDate(new Date(Date.UTC(2026, 0, 15)), 15)
|
|
expect(result).toBe('2026-02-15')
|
|
})
|
|
|
|
it('clamps day 31 to last day of February (non-leap)', () => {
|
|
// 2027 February has 28 days.
|
|
const result = computeNextRunDate(new Date(Date.UTC(2027, 0, 31)), 31)
|
|
expect(result).toBe('2027-02-28')
|
|
})
|
|
|
|
it('clamps day 31 to last day of February in a leap year', () => {
|
|
const result = computeNextRunDate(new Date(Date.UTC(2028, 0, 31)), 31)
|
|
expect(result).toBe('2028-02-29')
|
|
})
|
|
|
|
it('rolls into the next year correctly', () => {
|
|
const result = computeNextRunDate(new Date(Date.UTC(2026, 11, 15)), 15)
|
|
expect(result).toBe('2027-01-15')
|
|
})
|
|
|
|
it('clamps day 31 to 30 in 30-day months (April)', () => {
|
|
const result = computeNextRunDate(new Date(Date.UTC(2026, 2, 31)), 31)
|
|
expect(result).toBe('2026-04-30')
|
|
})
|
|
|
|
it('rejects invalid day_of_month', () => {
|
|
expect(() => computeNextRunDate(new Date(), 0)).toThrow()
|
|
expect(() => computeNextRunDate(new Date(), 32)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('computeInitialRunDate', () => {
|
|
it('picks this month when day_of_month is in the future', () => {
|
|
const today = new Date(Date.UTC(2026, 4, 5)) // 2026-05-05
|
|
expect(computeInitialRunDate(today, 15)).toBe('2026-05-15')
|
|
})
|
|
|
|
it('picks today when day_of_month === today', () => {
|
|
const today = new Date(Date.UTC(2026, 4, 15))
|
|
expect(computeInitialRunDate(today, 15)).toBe('2026-05-15')
|
|
})
|
|
|
|
it('picks next month when day_of_month is in the past', () => {
|
|
const today = new Date(Date.UTC(2026, 4, 20))
|
|
expect(computeInitialRunDate(today, 15)).toBe('2026-06-15')
|
|
})
|
|
|
|
it('honours start_date override', () => {
|
|
const today = new Date(Date.UTC(2026, 4, 20))
|
|
expect(computeInitialRunDate(today, 15, '2027-01-01')).toBe('2027-01-01')
|
|
})
|
|
|
|
it('clamps day 31 in February when picking this-month', () => {
|
|
const today = new Date(Date.UTC(2027, 1, 10)) // 2027-02-10, Feb has 28 days
|
|
expect(computeInitialRunDate(today, 31)).toBe('2027-02-28')
|
|
})
|
|
})
|
|
|
|
describe('getStockholmDateHour', () => {
|
|
it('applies summer offset (CEST, UTC+2)', () => {
|
|
// 2026-07-06 06:00 UTC -> 08:00 Stockholm
|
|
expect(getStockholmDateHour(new Date('2026-07-06T06:00:00Z'))).toEqual({
|
|
date: '2026-07-06',
|
|
hour: 8,
|
|
})
|
|
})
|
|
|
|
it('applies winter offset (CET, UTC+1)', () => {
|
|
// 2026-01-15 06:00 UTC -> 07:00 Stockholm
|
|
expect(getStockholmDateHour(new Date('2026-01-15T06:00:00Z'))).toEqual({
|
|
date: '2026-01-15',
|
|
hour: 7,
|
|
})
|
|
})
|
|
|
|
it('rolls the date forward across the local midnight boundary', () => {
|
|
// 2026-07-06 22:30 UTC -> 00:30 Stockholm on 2026-07-07 (summer +2)
|
|
expect(getStockholmDateHour(new Date('2026-07-06T22:30:00Z'))).toEqual({
|
|
date: '2026-07-07',
|
|
hour: 0,
|
|
})
|
|
})
|
|
|
|
it('reports hour 23 (h23 cycle, never 24) late in the local day', () => {
|
|
// 2026-07-06 21:00 UTC -> 23:00 Stockholm (summer +2)
|
|
expect(getStockholmDateHour(new Date('2026-07-06T21:00:00Z'))).toEqual({
|
|
date: '2026-07-06',
|
|
hour: 23,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('executeRecurringSchedule auto-send', () => {
|
|
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
|
const client = supabase as unknown as SupabaseClient
|
|
const today = new Date('2026-07-06T06:30:00Z')
|
|
|
|
const customer = makeCustomer({
|
|
id: 'cust-1',
|
|
name: 'Kund ÅÄÖ AB',
|
|
email: 'kund@test.se',
|
|
})
|
|
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() {
|
|
return {
|
|
id: 'sched-1',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
customer_id: 'cust-1',
|
|
name: 'Monthly retainer',
|
|
day_of_month: 6,
|
|
send_hour: 8,
|
|
payment_terms_days: 30,
|
|
currency: 'SEK',
|
|
your_reference: null,
|
|
our_reference: null,
|
|
notes: null,
|
|
auto_send: true,
|
|
status: 'active',
|
|
next_run_date: '2026-07-06',
|
|
last_run_at: null,
|
|
last_invoice_id: null,
|
|
last_run_warning: null,
|
|
generated_count: 0,
|
|
items: [
|
|
{
|
|
id: 'si-1',
|
|
schedule_id: 'sched-1',
|
|
sort_order: 0,
|
|
description: 'Konsulttimmar',
|
|
quantity: 10,
|
|
unit: 'tim',
|
|
unit_price: 1000,
|
|
vat_rate: 25,
|
|
},
|
|
],
|
|
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
|
|
}
|
|
|
|
// Fresh objects per test: ensureInvoiceNumber and applyPaymentLinkToInvoice
|
|
// mutate the invoice they receive, so shared fixtures would leak state.
|
|
function makeInsertedInvoice() {
|
|
return { id: 'inv-1', invoice_number: null, document_type: 'invoice' }
|
|
}
|
|
function makeCompleteInvoice() {
|
|
return {
|
|
id: 'inv-1',
|
|
invoice_number: 'F-1',
|
|
invoice_date: '2026-07-06',
|
|
status: 'draft',
|
|
document_type: 'invoice',
|
|
currency: 'SEK',
|
|
total: 12500,
|
|
credited_invoice_id: null,
|
|
payment_link_url: null,
|
|
customer,
|
|
items: [{ id: 'item-1', sort_order: 0 }],
|
|
}
|
|
}
|
|
|
|
/** Queue for the full happy path (see call order in the service). */
|
|
function enqueueHappyPath() {
|
|
enqueue({ data: customer, error: null }) // customers select
|
|
enqueue({ data: makeInsertedInvoice(), error: null }) // invoices insert
|
|
enqueue({ data: null, error: null }) // invoice_items insert
|
|
enqueue({ data: makeCompleteInvoice(), error: null }) // re-fetch with relations
|
|
enqueue({ data: company, error: null }) // company_settings (auto-send)
|
|
enqueue({ data: null, error: null }) // status flip to sent
|
|
enqueue({ data: null, error: null }) // journal_entry_id write-back
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
eventBus.clear()
|
|
mockIsConfigured.mockReturnValue(true)
|
|
mockIsSandbox.mockResolvedValue(false)
|
|
mockHasCapability.mockResolvedValue(true)
|
|
mockApplyPaymentLink.mockResolvedValue({ failure: null })
|
|
mockEnsureNumber.mockImplementation(
|
|
async (_supabase: unknown, _companyId: unknown, inv: { invoice_number: string | null }) => {
|
|
inv.invoice_number = 'F-1'
|
|
return 'F-1'
|
|
},
|
|
)
|
|
mockPrepareRender.mockResolvedValue({ branding: {}, company })
|
|
mockSwishQr.mockResolvedValue(null)
|
|
mockPaymentLinkQr.mockResolvedValue(null)
|
|
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
|
mockInvoicePDF.mockReturnValue('pdf-element')
|
|
mockSendEmail.mockResolvedValue({ success: true, messageId: 'm-1' })
|
|
mockCreateJE.mockResolvedValue({ id: 'je-1' })
|
|
mockUploadDocument.mockResolvedValue({})
|
|
})
|
|
|
|
it('creates a payment link before rendering and passes its QR to the PDF', async () => {
|
|
enqueueHappyPath()
|
|
mockApplyPaymentLink.mockImplementation(
|
|
async (_s: unknown, _c: unknown, _u: unknown, inv: { payment_link_url: string | null }) => {
|
|
inv.payment_link_url = 'https://pay.example/x'
|
|
return { failure: null }
|
|
},
|
|
)
|
|
mockPaymentLinkQr.mockResolvedValue('data:image/png;base64,QR')
|
|
|
|
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
|
|
|
expect(result.autoSent).toBe(true)
|
|
expect(result.warning).toBeNull()
|
|
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
|
|
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(
|
|
expect.anything(),
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({ id: 'inv-1' }),
|
|
expect.anything(),
|
|
)
|
|
// Link applied BEFORE the render so the email button and PDF QR carry it.
|
|
expect(mockApplyPaymentLink.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mockRenderToBuffer.mock.invocationCallOrder[0],
|
|
)
|
|
// QR built from the renderable copy (status overridden to 'sent').
|
|
expect(mockPaymentLinkQr).toHaveBeenCalledWith(
|
|
expect.objectContaining({ payment_link_url: 'https://pay.example/x', status: 'sent' }),
|
|
)
|
|
expect(mockInvoicePDF).toHaveBeenCalledWith(
|
|
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
|
|
)
|
|
expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
attachments: [expect.objectContaining({
|
|
filename: 'Oppy Sverige x Kund ÅÄÖ AB Faktura nr F-1 20260706.pdf',
|
|
})],
|
|
}))
|
|
})
|
|
|
|
it('a payment link failure never blocks the send', async () => {
|
|
enqueueHappyPath()
|
|
mockApplyPaymentLink.mockResolvedValue({ failure: 'Stripe nere' })
|
|
|
|
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
|
|
|
expect(result.autoSent).toBe(true)
|
|
expect(result.warning).toBeNull()
|
|
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
|
|
// queue only covers invoice creation.
|
|
enqueue({ data: customer, error: null })
|
|
enqueue({ data: makeInsertedInvoice(), error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: makeCompleteInvoice(), error: null })
|
|
|
|
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
|
|
|
expect(result.invoiceId).toBe('inv-1')
|
|
expect(result.autoSent).toBe(false)
|
|
expect(result.warning).toContain('Auto-utskick misslyckades')
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
expect(mockApplyPaymentLink).not.toHaveBeenCalled()
|
|
expect(mockCreateJE).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('route-level suppressAutoSend skips the send path without relying on the internal chokepoint', async () => {
|
|
// Defence in depth (ASVS V2.3): the flag comes from the route's own
|
|
// isSandboxCompany resolution, so sending is suppressed even before the
|
|
// service-internal sandbox check runs. Invoice creation is unaffected.
|
|
enqueue({ data: customer, error: null })
|
|
enqueue({ data: makeInsertedInvoice(), error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: makeCompleteInvoice(), error: null })
|
|
|
|
const result = await executeRecurringSchedule(client, makeSchedule(), today, {
|
|
suppressAutoSend: true,
|
|
})
|
|
|
|
expect(result.invoiceId).toBe('inv-1')
|
|
expect(result.autoSent).toBe(false)
|
|
expect(result.warning).toContain('Auto-utskick misslyckades')
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
// The suppress branch bails before the email chokepoint entirely.
|
|
expect(mockIsSandbox).not.toHaveBeenCalled()
|
|
expect(mockCreateJE).not.toHaveBeenCalled()
|
|
})
|
|
})
|