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>
This commit is contained in:
Jakob Wennberg
2026-07-29 09:38:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 65c6d4c178
commit 951b33363b
12 changed files with 539 additions and 6 deletions
+7
View File
@@ -707,6 +707,9 @@ export const RecurringScheduleItemSchema = z.object({
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.nullable()
.optional(),
// Copied onto the generated invoice_items.dimensions; merges over the
// schedule's default_dimensions on that item's revenue line.
dimensions: DimensionsBagSchema.optional(),
})
export const CreateRecurringScheduleSchema = z.object({
@@ -721,6 +724,8 @@ export const CreateRecurringScheduleSchema = z.object({
our_reference: z.string().optional(),
notes: z.string().optional(),
auto_send: z.boolean().default(false),
// Copied onto invoices.default_dimensions for every generated invoice.
default_dimensions: DimensionsBagSchema.optional(),
// Optional: when to first run. Defaults to next occurrence of day_of_month
// (today if day_of_month === today, otherwise next month).
start_date: isoDate.optional(),
@@ -739,6 +744,8 @@ export const UpdateRecurringScheduleSchema = z.object({
notes: z.string().nullable().optional(),
auto_send: z.boolean().optional(),
status: z.enum(['active', 'paused']).optional(),
// Replaces the whole bag if provided ({} clears all tags). Omit to keep.
default_dimensions: DimensionsBagSchema.optional(),
// Replace all items if provided. Omit to keep existing items unchanged.
items: z.array(RecurringScheduleItemSchema).min(1).optional(),
})
@@ -646,3 +646,136 @@ describe('executeRecurringSchedule foreign-currency rate fetch', () => {
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)
})
})
@@ -291,6 +291,10 @@ export async function executeRecurringSchedule(
your_reference: schedule.your_reference,
our_reference: schedule.our_reference,
notes: schedule.notes,
// Carried verbatim so cron-spawned invoices book with the same
// dimension tags a manually created invoice would (PR7 propagation
// in lib/bookkeeping/invoice-entries.ts reads these columns).
default_dimensions: schedule.default_dimensions ?? {},
document_type: 'invoice',
})
.select()
@@ -320,6 +324,7 @@ export async function executeRecurringSchedule(
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
dimensions: item.dimensions ?? {},
}
})
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
+5 -1
View File
@@ -548,6 +548,7 @@ async function commitCreateRecurringSchedule(
our_reference: validated.our_reference ?? null,
notes: validated.notes ?? null,
auto_send: validated.auto_send,
default_dimensions: validated.default_dimensions ?? {},
next_run_date: nextRunDate,
status: 'active',
})
@@ -566,6 +567,7 @@ async function commitCreateRecurringSchedule(
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
@@ -707,7 +709,7 @@ async function commitUpdateRecurringSchedule(
// and silently skip billing dates.
const { data: previousItems } = await supabase
.from('recurring_invoice_schedule_items')
.select('sort_order, description, quantity, unit, unit_price, vat_rate')
.select('sort_order, description, quantity, unit, unit_price, vat_rate, dimensions')
.eq('schedule_id', scheduleId)
await supabase
@@ -723,6 +725,7 @@ async function commitUpdateRecurringSchedule(
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
@@ -740,6 +743,7 @@ async function commitUpdateRecurringSchedule(
unit: row.unit,
unit_price: row.unit_price,
vat_rate: row.vat_rate,
dimensions: row.dimensions ?? {},
}))
const { error: restoreError } = await supabase
.from('recurring_invoice_schedule_items')