diff --git a/DECISIONS.md b/DECISIONS.md index c836255e..df81fd41 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -644,3 +644,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/invoices/recurring/[id]/route.ts b/app/api/invoices/recurring/[id]/route.ts index cd79eafa..06c434e9 100644 --- a/app/api/invoices/recurring/[id]/route.ts +++ b/app/api/invoices/recurring/[id]/route.ts @@ -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') diff --git a/app/api/invoices/recurring/__tests__/route.test.ts b/app/api/invoices/recurring/__tests__/route.test.ts index fe906582..28dfb3b4 100644 --- a/app/api/invoices/recurring/__tests__/route.test.ts +++ b/app/api/invoices/recurring/__tests__/route.test.ts @@ -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 = {} + 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 + > + 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', diff --git a/app/api/invoices/recurring/route.ts b/app/api/invoices/recurring/route.ts index 73d5c1f5..d1e764ea 100644 --- a/app/api/invoices/recurring/route.ts +++ b/app/api/invoices/recurring/route.ts @@ -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 diff --git a/extensions/general/mcp-server/__tests__/recurring-schedule-tools.test.ts b/extensions/general/mcp-server/__tests__/recurring-schedule-tools.test.ts index cb693141..de40e821 100644 --- a/extensions/general/mcp-server/__tests__/recurring-schedule-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/recurring-schedule-tools.test.ts @@ -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['supabase']) { + const inserted: Record = {} + 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 } + + expect(result.staged).toBe(true) + const opRow = inserted['pending_operations'][0] as { params: Record } + expect(opRow.params.default_dimensions).toEqual({ '1': 'KS1' }) + const items = opRow.params.items as Array> + 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 } + + expect(result.staged).toBe(true) + const opRow = inserted['pending_operations'][0] as { params: Record } + expect(opRow.params.default_dimensions).toEqual({ '6': 'P001' }) + const resolutions = result.preview.dimension_resolutions as Array> + 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 + changes: Record + proposed: Record + } + } + + expect(result.staged).toBe(true) + expect(result.preview.changes.default_dimensions).toEqual({}) + expect(result.preview.proposed.default_dimensions).toEqual({}) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 41feaba6..a7814c87 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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>) + : [] + 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 = {} 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>) + : [] + 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 = {} 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, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 1ca6c8d3..2ec9e2c2 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), }) diff --git a/lib/invoices/__tests__/recurring-schedule-service.test.ts b/lib/invoices/__tests__/recurring-schedule-service.test.ts index fde489bd..76962ae9 100644 --- a/lib/invoices/__tests__/recurring-schedule-service.test.ts +++ b/lib/invoices/__tests__/recurring-schedule-service.test.ts @@ -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 = {} + + 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[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> + 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 + delete schedule.default_dimensions + for (const item of schedule.items as Array>) { + delete item.dimensions + } + + await executeRecurringSchedule( + client, + schedule as unknown as Parameters[1], + today, + { suppressAutoSend: true }, + ) + + expect(inserted['invoices'][0]).toMatchObject({ default_dimensions: {} }) + const itemRows = inserted['invoice_items'][0] as Array> + expect(itemRows.every((row) => JSON.stringify(row.dimensions) === '{}')).toBe(true) + }) +}) diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index d3d112f5..85a78d74 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -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) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e649c1fa..48081bdb 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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') diff --git a/supabase/migrations/20260729100000_recurring_schedule_dimensions.sql b/supabase/migrations/20260729100000_recurring_schedule_dimensions.sql new file mode 100644 index 00000000..2f6a241e --- /dev/null +++ b/supabase/migrations/20260729100000_recurring_schedule_dimensions.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index c5b63ee7..4e40b47c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 + 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 created_at: string }