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
@@ -390,3 +390,161 @@ describe('gnubok_update_recurring_schedule: validation and staging', () => {
expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
})
})
describe('recurring schedule tools: dimension bags', () => {
const PROJEKT_DIM = {
id: 'dim-6',
sie_dim_no: 6,
name: 'Projekt',
resets_annually: false,
is_system: true,
is_active: true,
sort_order: 2,
}
const PROJEKT_VALUE = {
id: 'dv-1',
dimension_id: 'dim-6',
code: 'P001',
name: 'Villa Almgren',
is_active: true,
start_date: null,
end_date: null,
}
/**
* 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. Lets the tests assert what actually lands in
* pending_operations.params.
*/
function captureInserts(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
const inserted: Record<string, unknown[]> = {}
const originalFrom = supabase.from.getMockImplementation()!
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)
},
})
})
return inserted
}
it('create: stages schedule + item bags verbatim while dimensions are disabled (free-text passthrough)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserted = captureInserts(supabase)
enqueue({ data: { dimensions_enabled: false } }) // company_settings (resolver)
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
enqueue({ data: { id: 'op-dims-1' } }) // pending_operations insert
const result = (await createTool().execute(
{
customer_id: CUSTOMER_ID,
name: 'Projekt-retainer',
day_of_month: 25,
default_dimensions: { '1': 'KS1' },
items: [
{ description: 'Support', quantity: 1, unit: 'st', unit_price: 5000, dimensions: { '6': 'P001' } },
{ description: 'Timmar', quantity: 2, unit: 'tim', unit_price: 1000 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const opRow = inserted['pending_operations'][0] as { params: Record<string, unknown> }
expect(opRow.params.default_dimensions).toEqual({ '1': 'KS1' })
const items = opRow.params.items as Array<Record<string, unknown>>
expect(items[0].dimensions).toEqual({ '6': 'P001' })
// Untagged template items stage without the key: the commit executor and
// the cron both treat a missing bag as {}.
expect(items[1]).not.toHaveProperty('dimensions')
expect(result.preview.default_dimensions).toEqual({ '1': 'KS1' })
})
it('create: resolves a value NAME to its registry code and echoes the resolution', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserted = captureInserts(supabase)
enqueue({ data: { dimensions_enabled: true } }) // company_settings
enqueue({ data: null }) // ensure_company_dimensions rpc
enqueue({ data: [PROJEKT_DIM] }) // dimensions
enqueue({ data: [PROJEKT_VALUE] }) // dimension_values
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
enqueue({ data: { id: 'op-dims-2' } })
const result = (await createTool().execute(
{
customer_id: CUSTOMER_ID,
name: 'Villaprojektet',
day_of_month: 25,
default_dimensions: { '6': 'Villa Almgren' },
items: [{ description: 'Support', quantity: 1, unit: 'st', unit_price: 5000 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const opRow = inserted['pending_operations'][0] as { params: Record<string, unknown> }
expect(opRow.params.default_dimensions).toEqual({ '6': 'P001' })
const resolutions = result.preview.dimension_resolutions as Array<Record<string, unknown>>
expect(resolutions.length).toBeGreaterThan(0)
})
it('create: rejects an unknown dimension value instead of auto-creating it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true } })
enqueue({ data: null })
enqueue({ data: [PROJEKT_DIM] })
enqueue({ data: [PROJEKT_VALUE] })
await expect(
createTool().execute(
{
customer_id: CUSTOMER_ID,
name: 'X',
day_of_month: 25,
default_dimensions: { '6': 'Helt Okänt Projekt' },
items: [{ description: 'S', quantity: 1, unit: 'st', unit_price: 100 }],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/matcha|Kandidater|gnubok_create_dimension_value/i)
})
it('update: stages {} as the clear-all-tags bag replace', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
enqueue({ data: { id: 'op-dims-3' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, default_dimensions: {} },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
current: Record<string, unknown>
changes: Record<string, unknown>
proposed: Record<string, unknown>
}
}
expect(result.staged).toBe(true)
expect(result.preview.changes.default_dimensions).toEqual({})
expect(result.preview.proposed.default_dimensions).toEqual({})
})
})
+95 -4
View File
@@ -14817,6 +14817,10 @@ export const tools: McpTool[] = [
last_run_warning: { type: ['string', 'null'] },
generated_count: { type: 'number' },
monthly_total_excl_vat: { type: 'number' },
default_dimensions: {
type: 'object',
description: 'Dims bag {sie_dim_no: code} copied onto every generated invoice',
},
items: {
type: 'array',
items: {
@@ -14827,6 +14831,7 @@ export const tools: McpTool[] = [
unit: { type: 'string' },
unit_price: { type: 'number' },
vat_rate: { type: ['number', 'null'], description: 'null = customer default at spawn time' },
dimensions: { type: 'object', description: 'Per-item dims bag; wins per key over default_dimensions' },
},
},
},
@@ -14846,7 +14851,7 @@ export const tools: McpTool[] = [
let query = supabase
.from('recurring_invoice_schedules')
.select(
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, auto_send, next_run_date, last_run_at, last_invoice_id, last_run_warning, generated_count, customer:customers(name), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, sort_order)',
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, auto_send, default_dimensions, next_run_date, last_run_at, last_invoice_id, last_run_warning, generated_count, customer:customers(name), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
{ count: 'exact' },
)
.eq('company_id', companyId)
@@ -14871,6 +14876,7 @@ export const tools: McpTool[] = [
unit: it.unit,
unit_price: it.unit_price,
vat_rate: it.vat_rate ?? null,
dimensions: it.dimensions ?? {},
}))
const monthlyTotalExclVat =
Math.round(items.reduce((sum, it) => sum + Number(it.quantity) * Number(it.unit_price), 0) * 100) / 100
@@ -14891,6 +14897,7 @@ export const tools: McpTool[] = [
last_run_warning: row.last_run_warning ?? null,
generated_count: row.generated_count,
monthly_total_excl_vat: monthlyTotalExclVat,
default_dimensions: row.default_dimensions ?? {},
items,
}
})
@@ -14936,6 +14943,11 @@ export const tools: McpTool[] = [
description: 'Default false: invoices are created as drafts for manual review. true emails every generated invoice to the customer with no further approval; requires the customer to have an email address.',
},
start_date: { type: 'string', description: 'YYYY-MM-DD first run date. Omit to run on the next occurrence of day_of_month.' },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"6":"P001"}. Copied onto every generated invoice. Unknown values rejected: never auto-created.',
},
items: {
type: 'array',
minItems: 1,
@@ -14952,6 +14964,11 @@ export const tools: McpTool[] = [
enum: [0, 6, 12, 25, null],
description: 'Omit or null to use the customer default VAT rate at spawn time.',
},
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.',
},
},
required: ['description', 'quantity', 'unit_price'],
},
@@ -14969,6 +14986,27 @@ export const tools: McpTool[] = [
},
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
// Resolve-don't-select: parse the schedule-level default bag + each
// item's own bag, then resolve codes AND natural-language names against
// the registry in ONE pass (mirrors gnubok_create_invoice). The staged
// params carry only resolved codes; the cron copies them verbatim onto
// every generated invoice.
const rawItems = Array.isArray(args.items)
? (args.items as Array<Record<string, unknown>>)
: []
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[defaultDimensions, ...rawItems.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))],
)
const resolvedDefaultDimensions = resolvedDimBags[0]
const stagedItems = rawItems.map((item, i) => {
const { dimensions: _rawDimensions, ...rest } = item
const bag = resolvedDimBags[i + 1]
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
})
const candidate: Record<string, unknown> = {}
for (const key of [
'customer_id',
@@ -14982,10 +15020,17 @@ export const tools: McpTool[] = [
'notes',
'auto_send',
'start_date',
'items',
]) {
if (args[key] !== undefined) candidate[key] = args[key]
}
if (args.items !== undefined) {
// Non-array garbage passes through verbatim so the schema error below
// names the real problem instead of a synthetic empty list.
candidate.items = Array.isArray(args.items) ? stagedItems : args.items
}
if (resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0) {
candidate.default_dimensions = resolvedDefaultDimensions
}
const parsed = CreateRecurringScheduleParamsSchema.safeParse(candidate)
if (!parsed.success) {
@@ -15025,6 +15070,12 @@ export const tools: McpTool[] = [
projected_first_run_date: computeInitialRunDate(new Date(), params.day_of_month, params.start_date),
monthly_total_excl_vat: monthlyTotalExclVat,
items: params.items,
...(params.default_dimensions && Object.keys(params.default_dimensions).length > 0
? { default_dimensions: params.default_dimensions }
: {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
}
return stagePendingOperation(supabase, companyId, userId, 'create_recurring_schedule',
@@ -15077,6 +15128,11 @@ export const tools: McpTool[] = [
enum: ['active', 'paused'],
description: 'paused stops generating invoices; active resumes. Reactivating from a stale date rolls next_run_date to the next future occurrence, never today.',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn} copied onto every generated invoice. Replaces the whole bag; {} clears all tags. Omit to keep.',
},
items: {
type: 'array',
minItems: 1,
@@ -15093,6 +15149,11 @@ export const tools: McpTool[] = [
enum: [0, 6, 12, 25, null],
description: 'Omit or null to use the customer default VAT rate at spawn time.',
},
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.',
},
},
required: ['description', 'quantity', 'unit_price'],
},
@@ -15110,6 +15171,24 @@ export const tools: McpTool[] = [
},
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
// Resolve-don't-select for both the replacement default bag and any
// per-item bags (mirrors gnubok_create_recurring_schedule). An explicit
// {} default_dimensions passes through as the clear-all-tags update.
const rawItems = Array.isArray(args.items)
? (args.items as Array<Record<string, unknown>>)
: []
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[defaultDimensions, ...rawItems.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))],
)
const stagedItems = rawItems.map((item, i) => {
const { dimensions: _rawDimensions, ...rest } = item
const bag = resolvedDimBags[i + 1]
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
})
const changes: Record<string, unknown> = {}
for (const key of [
'customer_id',
@@ -15123,10 +15202,17 @@ export const tools: McpTool[] = [
'notes',
'auto_send',
'status',
'items',
]) {
if (args[key] !== undefined) changes[key] = args[key]
}
if (args.default_dimensions !== undefined) {
changes.default_dimensions = resolvedDimBags[0] ?? {}
}
if (args.items !== undefined) {
// Non-array garbage passes through verbatim so the schema error below
// names the real problem instead of a synthetic empty list.
changes.items = Array.isArray(args.items) ? stagedItems : args.items
}
const parsed = UpdateRecurringScheduleParamsSchema.safeParse({
schedule_id: args.schedule_id,
@@ -15141,7 +15227,7 @@ export const tools: McpTool[] = [
const { data: current, error } = await supabase
.from('recurring_invoice_schedules')
.select(
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, your_reference, our_reference, notes, auto_send, next_run_date, customer:customers(name, email), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, sort_order)',
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, your_reference, our_reference, notes, auto_send, default_dimensions, next_run_date, customer:customers(name, email), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
)
.eq('id', parsed.data.schedule_id)
.eq('company_id', companyId)
@@ -15183,6 +15269,7 @@ export const tools: McpTool[] = [
unit: it.unit,
unit_price: it.unit_price,
vat_rate: it.vat_rate ?? null,
dimensions: it.dimensions ?? {},
}))
const currentPreview = {
@@ -15199,6 +15286,7 @@ export const tools: McpTool[] = [
our_reference: current.our_reference ?? null,
notes: current.notes ?? null,
auto_send: current.auto_send,
default_dimensions: current.default_dimensions ?? {},
next_run_date: current.next_run_date,
items: currentItems,
}
@@ -15220,6 +15308,9 @@ export const tools: McpTool[] = [
...fieldChanges,
...(newItems ? { items: newItems } : {}),
},
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
undefined,