Files
accounted/app/api/invoices/recurring/route.ts
T
Jakob WennbergandClaude Fable 5 951b33363b 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>
2026-07-29 09:38:52 +02:00

161 lines
5.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { CreateRecurringScheduleSchema } from '@/lib/api/schemas'
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
ensureInitialized()
export const GET = withRouteContext(
'recurring_invoice.list',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { data, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(id,name,email), items:recurring_invoice_schedule_items(*)')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
if (error) {
log.error('failed to list recurring schedules', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data })
},
)
export const POST = withRouteContext(
'recurring_invoice.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = CreateRecurringScheduleSchema.safeParse(rawBody)
if (!parsed.success) {
log.warn('recurring schedule validation failed', {
issueCount: parsed.error.issues.length,
})
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
},
{ status: 400 },
)
}
const input = parsed.data
// Verify the customer belongs to this company (defense in depth + clearer
// 404 than the FK violation we'd otherwise get).
const { data: customer } = await supabase
.from('customers')
.select('id, email')
.eq('id', input.customer_id)
.eq('company_id', companyId)
.maybeSingle()
if (!customer) {
return NextResponse.json(
{ error: 'Customer not found', type: 'not_found' },
{ status: 404 },
)
}
// auto_send without a customer email would silently degrade to a monthly
// draft + warning at cron time. Reject it up front instead; the dialog
// blocks this client-side, so this is the API backstop.
if (input.auto_send && !customer.email) {
return NextResponse.json(
{
error: 'Customer has no email address: automatic sending requires one',
type: 'validation_error',
},
{ status: 400 },
)
}
const nextRunDate = computeInitialRunDate(
new Date(),
input.day_of_month,
input.start_date,
)
const { data: schedule, error: insertError } = await supabase
.from('recurring_invoice_schedules')
.insert({
company_id: companyId,
user_id: user.id,
customer_id: input.customer_id,
name: input.name,
day_of_month: input.day_of_month,
send_hour: input.send_hour,
payment_terms_days: input.payment_terms_days,
currency: input.currency,
your_reference: input.your_reference ?? null,
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',
})
.select()
.single()
if (insertError || !schedule) {
log.error('failed to insert recurring schedule', insertError)
return errorResponse(insertError ?? new Error('insert failed'), log, { requestId })
}
const itemRows = input.items.map((item, idx) => ({
schedule_id: schedule.id,
sort_order: idx,
description: item.description,
quantity: item.quantity,
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')
.insert(itemRows)
if (itemsError) {
// Roll back the parent so a half-created schedule doesn't ship.
await supabase
.from('recurring_invoice_schedules')
.delete()
.eq('id', schedule.id)
.eq('company_id', companyId)
log.error('failed to insert schedule items; rolled back schedule', itemsError)
return errorResponse(itemsError, log, { requestId })
}
const { data: complete } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(id,name,email), items:recurring_invoice_schedule_items(*)')
.eq('id', schedule.id)
.single()
return NextResponse.json({ data: complete }, { status: 201 })
},
{ requireWrite: true },
)