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:
co-authored by
Claude Fable 5
parent
65c6d4c178
commit
951b33363b
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user