* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.1 KiB
TypeScript
162 lines
5.1 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,
|
|
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 },
|
|
)
|