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
+2
View File
@@ -644,3 +644,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-28] Preview honesty over prettier labels in the supplier-invoice voucher preview: the BESKRIVNING column now renders the exact line_description the engine will post, and the hardcoded ACCOUNT_LABELS map (11 accounts) was removed. That map made the column silently mix "friendly account label" (for its 11 entries) with "raw account number" (every expense account, the reported bug), and neither was the posted text. Account identity was not lost: AccountNumber already shows the BAS name on its hover card. The ankomstnummer suffix the engine appends is absent from the preview because it is assigned on save and does not exist yet at preview time.
[2026-07-28] The "senaste bokförda verifikat" line in the balans-/resultatrapport header (#1267) reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that provably drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails (the reversal path in engine.ts does exactly that), delete_last_voucher decrements blindly by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind MAX. Since the whole point of the line is avstämning, printing an allocated number would send a reconciler chasing a gap that does not exist, so the label states plainly that the number is the last posted one. Scoped to the report's own date range rather than the fiscal year, so a Q1 report printed in November says something true about Q1; the balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped entirely on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. No new i18n keys: both report views and the PDF template are hard-coded Swedish, per the "stays Swedish" report surfaces in .claude/rules/i18n.md, so the issue's acceptance criterion asking for sv+en strings does not apply here.
[2026-07-28] Recurring-schedule dims PR ships API/MCP/generator only, no schedule-dialog pickers: UI needs visual sign-off per house rule; substrate stops the cron-spawned-invoices-born-untagged leak now.
+3 -1
View File
@@ -198,7 +198,7 @@ export const PATCH = withRouteContext(
// items", silently skipping 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', id)
await supabase
@@ -214,6 +214,7 @@ export const PATCH = withRouteContext(
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
.from('recurring_invoice_schedule_items')
@@ -230,6 +231,7 @@ export const PATCH = withRouteContext(
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')
@@ -130,6 +130,91 @@ describe('POST /api/invoices/recurring', () => {
expect(body.type).toBe('validation_error')
})
it('rejects a malformed dimensions bag with 400', async () => {
const request = createMockRequest('/api/invoices/recurring', {
method: 'POST',
body: {
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Test',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
// Key must be a SIE dim number: 'projekt' is not.
default_dimensions: { projekt: 'P001' },
items: [
{ description: 'Service', quantity: 1, unit: 'st', unit_price: 1000 },
],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
})
it('persists schedule and item dimension bags', async () => {
// The queued mock's chain proxy discards call args by design, so capture
// .insert/.update payloads per table with a thin wrapper.
const inserted: Record<string, unknown[]> = {}
const originalFrom = mockSupabase.from.getMockImplementation()!
mockSupabase.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)
},
})
})
const createdSchedule = { id: 's-1', name: 'Acme retainer' }
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
enqueue({ data: createdSchedule, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
const request = createMockRequest('/api/invoices/recurring', {
method: 'POST',
body: {
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Acme retainer',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
default_dimensions: { '1': 'KS1', '6': 'P001' },
items: [
{
description: 'Konsultarvode',
quantity: 10,
unit: 'tim',
unit_price: 1200,
dimensions: { '6': 'P002' },
},
{ description: 'Resor', quantity: 1, unit: 'st', unit_price: 500 },
],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(201)
expect(inserted['recurring_invoice_schedules'][0]).toMatchObject({
default_dimensions: { '1': 'KS1', '6': 'P001' },
})
const itemRows = inserted['recurring_invoice_schedule_items'][0] as Array<
Record<string, unknown>
>
expect(itemRows[0].dimensions).toEqual({ '6': 'P002' })
expect(itemRows[1].dimensions).toEqual({})
})
it('creates a schedule on the happy path', async () => {
const createdSchedule = {
id: 's-1',
+2
View File
@@ -110,6 +110,7 @@ export const POST = withRouteContext(
our_reference: input.our_reference ?? null,
notes: input.notes ?? null,
auto_send: input.auto_send,
default_dimensions: input.default_dimensions ?? {},
next_run_date: nextRunDate,
status: 'active',
})
@@ -129,6 +130,7 @@ export const POST = withRouteContext(
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
@@ -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,
+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')
@@ -0,0 +1,37 @@
-- Recurring schedules carry dimension tags so cron-generated invoices are
-- born with the same {sie_dim_no: code} bags a manually created invoice
-- would have (dimensions PR7 producer parity).
--
-- recurring_invoice_schedules.default_dimensions
-- copied verbatim onto invoices.default_dimensions at spawn time; the
-- invoice entry generators then apply it to every journal line.
-- recurring_invoice_schedule_items.dimensions
-- copied onto invoice_items.dimensions per generated item; merged OVER
-- the invoice default on the revenue line that item books to.
--
-- Same shape + CHECK as invoices/invoice_items (20260702200000). No indexes:
-- read via their parent row when spawning invoices, never containment-queried.
-- NOT NULL DEFAULT '{}' is metadata-only on PG11+ (no table rewrite).
--
-- pg-test: covered-by — plain column adds with a type CHECK, no
-- trigger/RPC/RLS/DEFERRABLE change. Propagation logic is TS-side
-- (lib/invoices/recurring-schedule-service.ts unit tests).
ALTER TABLE public.recurring_invoice_schedules
ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.recurring_invoice_schedules
ADD CONSTRAINT recurring_invoice_schedules_default_dimensions_is_object
CHECK (jsonb_typeof(default_dimensions) = 'object');
ALTER TABLE public.recurring_invoice_schedule_items
ADD COLUMN dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.recurring_invoice_schedule_items
ADD CONSTRAINT recurring_invoice_schedule_items_dimensions_is_object
CHECK (jsonb_typeof(dimensions) = 'object');
COMMENT ON COLUMN public.recurring_invoice_schedules.default_dimensions IS
'Dimension bag {sie_dim_no: code} copied onto invoices.default_dimensions for every invoice this schedule generates. See lib/invoices/recurring-schedule-service.ts.';
COMMENT ON COLUMN public.recurring_invoice_schedule_items.dimensions IS
'Per-item dimension bag copied onto invoice_items.dimensions for the generated item; merges over the schedule default on the revenue line.';
NOTIFY pgrst, 'reload schema';
+7
View File
@@ -1242,6 +1242,10 @@ export interface RecurringInvoiceSchedule {
our_reference: string | null
notes: string | null
// Dimension bag {sie_dim_no: code} copied onto every generated invoice's
// default_dimensions at spawn time.
default_dimensions?: Record<string, string>
auto_send: boolean
status: RecurringInvoiceScheduleStatus
@@ -1269,6 +1273,9 @@ export interface RecurringInvoiceScheduleItem {
unit_price: number
// null = inherit customer's default VAT rate at spawn time
vat_rate: number | null
// Per-item bag copied onto the generated invoice_items.dimensions; merges
// over the schedule default on that item's revenue line.
dimensions?: Record<string, string>
created_at: string
}