* 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>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { UpdateRecurringScheduleSchema } from '@/lib/api/schemas'
|
|
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
|
|
import {
|
|
computeInitialRunDate,
|
|
computeNextRunDate,
|
|
rollNextRunDateForward,
|
|
getStockholmDateHour,
|
|
} from '@/lib/invoices/recurring-schedule-service'
|
|
|
|
ensureInitialized()
|
|
|
|
export const GET = withRouteContext(
|
|
'recurring_invoice.get',
|
|
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log } = ctx
|
|
const { data, error } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
log.warn('recurring schedule not found', { scheduleId: id })
|
|
return NextResponse.json(
|
|
{ error: 'Schedule not found', type: 'not_found' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
return NextResponse.json({ data })
|
|
},
|
|
)
|
|
|
|
export const PATCH = withRouteContext(
|
|
'recurring_invoice.update',
|
|
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { 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 = UpdateRecurringScheduleSchema.safeParse(rawBody)
|
|
if (!parsed.success) {
|
|
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
|
|
const { items, ...scheduleFields } = input
|
|
|
|
// Only forward fields the user actually supplied.
|
|
const updateRow: Record<string, unknown> = {}
|
|
for (const [k, v] of Object.entries(scheduleFields)) {
|
|
if (v !== undefined) updateRow[k] = v
|
|
}
|
|
|
|
// Turning auto_send on (or moving the schedule to another customer while
|
|
// it is on) requires the customer to have an email address; otherwise
|
|
// every cron run degrades to a draft + warning. Mirrors the create
|
|
// route's guard.
|
|
if (input.auto_send === true || input.customer_id !== undefined) {
|
|
const { data: current } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('auto_send, customer_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (!current) {
|
|
return NextResponse.json(
|
|
{ error: 'Schedule not found', type: 'not_found' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
const effectiveAutoSend = input.auto_send ?? current.auto_send
|
|
if (effectiveAutoSend) {
|
|
const { data: customer } = await supabase
|
|
.from('customers')
|
|
.select('email')
|
|
.eq('id', input.customer_id ?? current.customer_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
if (!customer?.email) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Customer has no email address: automatic sending requires one',
|
|
type: 'validation_error',
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recompute next_run_date when either the schedule is being reactivated
|
|
// (from a stale date) or its day-of-month actually changed via an edit.
|
|
if (input.status === 'active' || input.day_of_month !== undefined) {
|
|
const { data: existing } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('next_run_date, day_of_month, interval_months')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (!existing) {
|
|
return NextResponse.json(
|
|
{ error: 'Schedule not found', type: 'not_found' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
const reactivating = input.status === 'active'
|
|
const dayChanged =
|
|
input.day_of_month !== undefined && input.day_of_month !== existing.day_of_month
|
|
const effectiveDay = input.day_of_month ?? existing.day_of_month
|
|
const effectiveInterval = input.interval_months ?? existing.interval_months ?? 1
|
|
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
|
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
|
|
|
// Recompute to the next STRICTLY-future occurrence (never today, so an
|
|
// edit or reactivation can't trigger a same-hour surprise send; today's
|
|
// invoice is the explicit run-now action instead) when either:
|
|
// - reactivating a schedule whose date already passed (e.g. the safety
|
|
// pause when the send-hour cron shipped), or
|
|
// - the day-of-month changed, so "Nästa körning" follows the new day.
|
|
// Editing other fields (name, items, time, interval) leaves
|
|
// next_run_date alone, so an unrelated edit never skips an imminent
|
|
// send; a changed interval applies from the next run onward.
|
|
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
|
if (staleOnReactivate || dayChanged) {
|
|
if (effectiveInterval === 1) {
|
|
// Monthly keeps its long-standing semantics: re-anchor on today so
|
|
// a day edit lands on the new day's nearest future occurrence.
|
|
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
|
updateRow.next_run_date =
|
|
rolled === todayStockholm
|
|
? computeNextRunDate(stockholmToday, effectiveDay)
|
|
: rolled
|
|
} else {
|
|
// Interval schedules roll on their own month grid so a day edit or
|
|
// reactivation cannot shift a quarterly schedule off its
|
|
// Jan/Apr/Jul/Oct phase (or bill a quarter early).
|
|
updateRow.next_run_date = rollNextRunDateForward(
|
|
existing.next_run_date,
|
|
stockholmToday,
|
|
effectiveDay,
|
|
effectiveInterval,
|
|
)
|
|
}
|
|
}
|
|
|
|
// A conscious reactivation invalidates any lingering warning (the
|
|
// safety-pause note, or a stale failure from months ago).
|
|
if (reactivating) {
|
|
updateRow.last_run_warning = null
|
|
}
|
|
}
|
|
|
|
// Existence check BEFORE any write: with items in the payload the request
|
|
// writes two tables, so a 404 must not leave a header update behind.
|
|
if (items) {
|
|
const { data: existing } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (!existing) {
|
|
return NextResponse.json(
|
|
{ error: 'Schedule not found', type: 'not_found' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Items are replaced wholesale. Cheaper than diffing for a small list and
|
|
// matches how the UI form sends the full list back on every save. Both
|
|
// writes are compensated on failure inside the shared helper.
|
|
const result = await applyRecurringScheduleUpdate(supabase, {
|
|
scheduleId: id,
|
|
companyId,
|
|
fields: updateRow,
|
|
items,
|
|
log,
|
|
})
|
|
|
|
if (!result.ok) {
|
|
if (result.stage === 'header') {
|
|
log.error('failed to update recurring schedule', result.error)
|
|
return errorResponse(result.error, log, { requestId })
|
|
}
|
|
if (!result.itemsRestored || !result.headerRestored) {
|
|
// A compensation did not apply, so the schedule may be half-saved: say
|
|
// so instead of reporting a clean failure. Logged here with the repair
|
|
// context (errorResponseFromCode only records the code itself), which
|
|
// the clean-rollback path below does not need.
|
|
log.error('recurring schedule update left a partial state', result.error, {
|
|
scheduleId: id,
|
|
stage: result.stage,
|
|
itemsRestored: result.itemsRestored,
|
|
headerRestored: result.headerRestored,
|
|
})
|
|
return errorResponseFromCode('INVOICE_RECURRING_UPDATE_PARTIAL', log, {
|
|
requestId,
|
|
// camelCase throughout, matching the pgCode key errorResponse itself
|
|
// merges into details for Postgres failures.
|
|
details: {
|
|
pgCode: result.error.code,
|
|
stage: result.stage,
|
|
itemsRestored: result.itemsRestored,
|
|
headerRestored: result.headerRestored,
|
|
},
|
|
})
|
|
}
|
|
// Clean rollback: keep the PG-mapped error so a CHECK violation still
|
|
// surfaces its specific Swedish message. errorResponse logs it, so no
|
|
// second log line here.
|
|
return errorResponse(result.error, log, { requestId })
|
|
}
|
|
|
|
const { data: complete } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
return NextResponse.json({ data: complete })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
export const DELETE = withRouteContext(
|
|
'recurring_invoice.delete',
|
|
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
// Items cascade-delete via FK ON DELETE CASCADE.
|
|
const { error } = await supabase
|
|
.from('recurring_invoice_schedules')
|
|
.delete()
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (error) {
|
|
log.error('failed to delete recurring schedule', error)
|
|
return errorResponse(error, log, { requestId })
|
|
}
|
|
return NextResponse.json({ success: true })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|