Files
accounted/lib/invoices/__tests__/recurring-schedule-service.test.ts
T
Jakob WennbergandClaude Fable 5 951b33363b feat(recurring): carry dimension bags on recurring invoice schedules (#1272)
Schedules and their template items now store {sie_dim_no: code} bags
(default_dimensions / dimensions), and the cron generator copies them
onto every spawned invoice + item, so recurring invoices book with the
same projekt/kostnadsstalle tags a manual invoice would. Wired through
the web CRUD routes, the staged-operation executors, and the MCP
create/update/list schedule tools (resolve-don't-select, resolutions
echoed in the preview).

Migration 20260728090000 adds the two jsonb columns (same shape+CHECK
as invoices/invoice_items, PR7 producer parity).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:38:52 +02:00

782 lines
27 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 mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', async () => {
const actual =
await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
return { ...actual, fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...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()
})
})
describe('executeRecurringSchedule VAT rate gate', () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
const today = new Date('2026-07-06T06:30:00Z')
// Validated EU business: the picker default is a single locked 0%
// (huvudregeln, ML 6 kap. 34 §), while the ML 6 kap. supplies taxed where they
// are performed carry Swedish VAT to that same customer. The cron-time gate
// reads the wider permitted set, exactly like buildInvoiceWriteData.
const euCustomer = makeCustomer({
id: 'cust-1',
customer_type: 'eu_business',
vat_number_validated: true,
})
function makeScheduleWithRate(vatRate: number) {
return {
id: 'sched-1',
company_id: 'company-1',
user_id: 'user-1',
customer_id: 'cust-1',
name: 'Monthly hotel retainer',
day_of_month: 6,
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
your_reference: null,
our_reference: null,
notes: null,
auto_send: false,
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: 'Hotellnatt Stockholm',
quantity: 10,
unit: 'st',
unit_price: 1000,
vat_rate: vatRate,
},
],
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockEnsureNumber.mockResolvedValue('F-1')
})
it('generates the invoice for a 12% schedule to a validated EU business', async () => {
enqueue({ data: euCustomer, error: null }) // customers select
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null }) // invoices insert
enqueue({ data: null, error: null }) // invoice_items insert
enqueue({
data: { id: 'inv-1', invoice_number: 'F-1', customer: euCustomer, items: [] },
error: null,
}) // re-fetch
const result = await executeRecurringSchedule(client, makeScheduleWithRate(12), today, {
suppressAutoSend: true,
})
expect(result.invoiceId).toBe('inv-1')
})
it('still throws for a rate that is not a Swedish VAT rate', async () => {
enqueue({ data: euCustomer, error: null }) // customers select; throws before any insert
await expect(
executeRecurringSchedule(client, makeScheduleWithRate(10), today, { suppressAutoSend: true }),
).rejects.toThrow(/VAT rate 10% not allowed/)
})
})
describe('executeRecurringSchedule foreign-currency rate fetch', () => {
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' })
function makeEurSchedule() {
return {
id: 'sched-1',
company_id: 'company-1',
user_id: 'user-1',
customer_id: 'cust-1',
name: 'Monthly EUR retainer',
day_of_month: 6,
send_hour: 8,
payment_terms_days: 30,
currency: 'EUR',
your_reference: null,
our_reference: null,
notes: null,
auto_send: false,
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: 100,
vat_rate: 25,
},
],
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
}
function enqueueCreateOnlyPath() {
enqueue({ data: customer, error: null }) // customers select
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null }) // invoices insert
enqueue({ data: null, error: null }) // invoice_items insert
enqueue({
data: { id: 'inv-1', invoice_number: 'F-1', customer, items: [] },
error: null,
}) // re-fetch with relations
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockEnsureNumber.mockResolvedValue('F-1')
})
it('fetches the rate for the invoice date through the shared cache client', async () => {
// The cron used to call fetchExchangeRate(currency) with neither the date
// nor the supabase client, skipping the exchange_rates cache and the
// Riksbanken-429 fallback that build-invoice-write.ts uses: one transient
// rate limit left the monthly foreign retainer with exchange_rate NULL
// forever. The call shape must match buildInvoiceWriteData's.
mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.5, date: '2026-07-04' })
enqueueCreateOnlyPath()
const result = await executeRecurringSchedule(client, makeEurSchedule(), today, {
suppressAutoSend: true,
})
expect(result.invoiceId).toBe('inv-1')
expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1)
const [currencyArg, dateArg, clientArg] = mockFetchExchangeRate.mock.calls[0]
expect(currencyArg).toBe('EUR')
expect((dateArg as Date).toISOString().slice(0, 10)).toBe('2026-07-06')
expect(clientArg).toBe(client)
})
it('keeps continue-on-null: a missing rate never fails the cron run', async () => {
mockFetchExchangeRate.mockResolvedValue(null)
enqueueCreateOnlyPath()
const result = await executeRecurringSchedule(client, makeEurSchedule(), today, {
suppressAutoSend: true,
})
expect(result.invoiceId).toBe('inv-1')
})
})
describe('executeRecurringSchedule dimension propagation', () => {
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', email: 'kund@test.se' })
// The queued mock's chain proxy discards call args by design, so capture
// .insert payloads per table with a thin wrapper around the original
// implementation (grabbed once, before any override, to avoid re-wrapping).
const originalFrom = supabase.from.getMockImplementation()!
const inserted: Record<string, unknown[]> = {}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
for (const key of Object.keys(inserted)) delete inserted[key]
supabase.from.mockImplementation((table: string) => {
const chain = originalFrom(table) as object
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'insert') {
return (rows: unknown) => {
;(inserted[table] ??= []).push(rows)
return (Reflect.get(target, prop, receiver) as (r: unknown) => unknown)(rows)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
})
function makeTaggedSchedule() {
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: false,
status: 'active',
next_run_date: '2026-07-06',
last_run_at: null,
last_invoice_id: null,
last_run_warning: null,
generated_count: 0,
default_dimensions: { '1': 'KS1', '6': 'P001' },
items: [
{
id: 'si-1',
schedule_id: 'sched-1',
sort_order: 0,
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
dimensions: { '6': 'P002' },
},
{
id: 'si-2',
schedule_id: 'sched-1',
sort_order: 1,
description: 'Resersättning',
quantity: 1,
unit: 'st',
unit_price: 500,
vat_rate: 25,
},
],
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
}
function enqueueCreatePath() {
enqueue({ data: customer, error: null }) // customers select
enqueue({ data: { id: 'inv-1', invoice_number: null, document_type: 'invoice' }, error: null }) // invoices insert
enqueue({ data: null, error: null }) // invoice_items insert
enqueue({
data: { id: 'inv-1', invoice_number: 'F-1', customer, items: [] },
error: null,
}) // re-fetch with relations
}
it('copies the schedule bag onto the invoice and per-item bags onto items', async () => {
enqueueCreatePath()
await executeRecurringSchedule(client, makeTaggedSchedule(), today, {
suppressAutoSend: true,
})
expect(inserted['invoices']).toHaveLength(1)
expect(inserted['invoices'][0]).toMatchObject({
default_dimensions: { '1': 'KS1', '6': 'P001' },
})
const itemRows = inserted['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(2)
expect(itemRows[0].dimensions).toEqual({ '6': 'P002' })
// An untagged template item lands as an explicit empty bag, matching the
// invoice_items column default (never undefined/null).
expect(itemRows[1].dimensions).toEqual({})
})
it('a legacy schedule row without the columns spawns empty bags', async () => {
enqueueCreatePath()
const schedule = makeTaggedSchedule() as unknown as Record<string, unknown>
delete schedule.default_dimensions
for (const item of schedule.items as Array<Record<string, unknown>>) {
delete item.dimensions
}
await executeRecurringSchedule(
client,
schedule as unknown as Parameters<typeof executeRecurringSchedule>[1],
today,
{ suppressAutoSend: true },
)
expect(inserted['invoices'][0]).toMatchObject({ default_dimensions: {} })
const itemRows = inserted['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows.every((row) => JSON.stringify(row.dimensions) === '{}')).toBe(true)
})
})