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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user