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
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user