Files
accounted/app/api/invoices/recurring/route.ts
T
MattssonandClaude Fable 5.1 238cbe13f9 feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules

A yearly or quarterly recurring schedule had no way to say which month it
bills in: the dialog exposed interval and day of month only, so a yearly
schedule created in September always fired in September. The phase of a
schedule is fully defined by its first run date, which the table already
stores as next_run_date and the create API already accepted as start_date
but nothing exposed.

- Dialog: new date field (first invoice date on create, next invoice date on
  edit), prefilled with the next natural occurrence so the default is "no
  offset"; kept in step with day of month both ways; shows the following
  three run dates so the phase is visible. Sent as start_date on create and
  as next_run_date on edit only when the user actually re-phased.
- API: create validates start_date (on the day_of_month grid, not in the
  past); update accepts next_run_date (on the grid for the effective day,
  strictly after today in Stockholm) and lets it win over the automatic
  recompute a day change or reactivation does.
- Staged operations / MCP: start_date documented as the phase; update tool
  gains next_run_date. Commit executor rejects off-grid dates and rolls a
  date that went stale before approval forward on its own grid.
- lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared
  by the dialog, the routes, the executors and the cron service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC

* fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog

Resolves the skeptic and CI findings on #2338 in one pass:

- MCP staging tools now apply the same grid and past/future rules as the
  routes to start_date and next_run_date, so the preview a human approves
  is exactly what the commit executor writes (previously an off-grid date
  staged fine and failed at approval, and a past next_run_date was rolled
  to another date silently).
- The dialog computes today and the default first invoice date in
  Europe/Stockholm instead of the browser's zone, matching the server;
  getStockholmDateHour moved to the client-safe module and is re-exported
  from the service.
- gnubok_update_recurring_schedule description trimmed under the 280-char
  limit while keeping the clamping and Stockholm phrases the registration
  test requires.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:49:29 +02:00

190 lines
6.2 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,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
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 },
)
}
// An explicit first run date fixes the month phase (yearly in February,
// quarterly Feb/May/Aug/Nov). It must sit on the schedule grid, or the
// cron would drift back to day_of_month after the first run, and it
// cannot be in the past: the dialog prefills the next occurrence, so a
// past date is a stale form, not an intent to backfill.
if (input.start_date !== undefined) {
if (!runDateMatchesDayOfMonth(input.start_date, input.day_of_month)) {
return NextResponse.json(
{
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
type: 'validation_error',
},
{ status: 400 },
)
}
const { date: todayStockholm } = getStockholmDateHour(new Date())
if (input.start_date < todayStockholm) {
return NextResponse.json(
{ error: 'start_date cannot be in the past', 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,
interval_months: input.interval_months,
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 },
)