* 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>
190 lines
6.2 KiB
TypeScript
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 },
|
|
)
|