Files
accounted/app/api/invoices/recurring/cron/route.ts
T
MattssonandClaude Fable 5 a0ca692fed feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* 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>
2026-08-06 15:43:54 +02:00

278 lines
11 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClient } from '@/lib/supabase/server'
import {
executeRecurringSchedule,
computeNextRunDate,
rollNextRunDateForward,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type {
RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem,
} from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
type DueSchedule = RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] }
/**
* GET /api/invoices/recurring/cron: hourly (top of every hour, UTC).
*
* Users pick a send hour in Swedish local time (send_hour, 0-23,
* Europe/Stockholm). This cron runs every hour and, for each active schedule
* due today, sends only once the chosen Stockholm hour has arrived.
*
* Safety rules (see DECISIONS.md):
* - Never send for a date in the past. A schedule whose next_run_date is
* before today (a missed prior day, e.g. after an outage or on a schedule
* the user just reactivated) is rolled forward to its next future
* occurrence WITHOUT generating anything.
* - Paused schedules are ignored (status filter). Existing schedules were
* paused on deploy so nothing resumes sending behind the user's back.
*
* Each schedule runs in isolated try/catch so a failure on one doesn't block
* the rest. On a successful send: bump next_run_date one interval_months
* step forward (1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly), set
* last_run_at/last_invoice_id/generated_count. On failure: leave next_run_date
* alone so a later run retries.
*/
export const GET = withCronContext('cron.recurring_invoices', async (_request, ctx) => {
const supabase = createServiceClient()
const now = new Date()
// "Today" and the current hour in Swedish local time. Date math for rolling
// next_run_date uses a UTC-midnight Date of the Stockholm calendar day so it
// stays consistent with the Stockholm day even across the UTC boundary.
const { date: todayStockholm, hour: currentHour } = getStockholmDateHour(now)
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
const { data: due, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, items:recurring_invoice_schedule_items(*)')
.eq('status', 'active')
.lte('next_run_date', todayStockholm)
if (error) {
ctx.log.error('failed to load due recurring schedules', error)
return NextResponse.json(
{ success: false, error: getUserErrorMessage(error) },
{ status: 500 },
)
}
const schedules = (due ?? []) as DueSchedule[]
ctx.log.info('recurring invoice cron starting', {
dueCount: schedules.length,
todayStockholm,
currentHour,
})
type RunResult = {
scheduleId: string
invoiceId?: string
invoiceNumber?: string | null
autoSent?: boolean
warning?: string | null
skipped?: boolean
skipReason?: string
error?: string
}
const results: RunResult[] = []
const summary = await ctx.forEach('schedule', schedules, async (schedule, itemCtx) => {
// 1. Missed a prior day: never send for the past. Roll forward to the next
// future occurrence (this month if the day hasn't passed, else next
// month) without generating an invoice. This also protects the
// reactivation path: turning a long-paused schedule back on rolls it to
// its next date rather than firing a stale one immediately.
if (schedule.next_run_date < todayStockholm) {
// Roll on the schedule's own month grid (anchored on the missed date,
// not on today) so a quarterly/yearly schedule keeps its phase: a
// Jan 15 quarterly run missed in an outage rolls to Apr 15, not Feb 15.
const rolledNext = rollNextRunDateForward(
schedule.next_run_date,
stockholmToday,
schedule.day_of_month,
schedule.interval_months,
{ allowToday: true },
)
// Surface the skip on the schedule: a day of failed runs (or a cron
// outage) would otherwise roll the month forward with no user-visible
// trace. The next successful run overwrites this, and a conscious
// reactivation clears it (PATCH route).
const { error: rollError } = await supabase
.from('recurring_invoice_schedules')
.update({
next_run_date: rolledNext,
last_run_warning: `Ingen faktura skapades den ${schedule.next_run_date}. Nästa körning: ${rolledNext}. Använd "Skapa faktura nu" om månadens faktura fortfarande behövs.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
if (rollError) {
throw new Error(`failed to roll stale schedule forward: ${rollError.message}`)
}
itemCtx.log.info('stale schedule rolled forward without sending', {
from: schedule.next_run_date,
to: rolledNext,
})
results.push({
scheduleId: schedule.id,
skipped: true,
skipReason: 'stale_rolled_forward',
})
return
}
// 2. Due today but the chosen Stockholm hour hasn't arrived yet. A later
// run this same day will pick it up (send_hour <= currentHour).
if (currentHour < schedule.send_hour) {
results.push({
scheduleId: schedule.id,
skipped: true,
skipReason: 'hour_not_reached',
})
return
}
// 3. Idempotency fast-path: skip if the row we loaded already shows a run
// today (cheap check against the batch, no write).
if (schedule.last_run_at) {
const lastRunDay = getStockholmDateHour(new Date(schedule.last_run_at)).date
if (lastRunDay >= todayStockholm) {
itemCtx.log.info('schedule already ran today; skipping')
results.push({
scheduleId: schedule.id,
skipped: true,
skipReason: 'already_ran_today',
})
return
}
}
// 4. Atomic claim. Two hourly cron invocations can overlap (an hour-boundary
// retry, or a manual re-trigger) and both read the same stale
// last_run_at from the batch above, so the read-only check in step 3
// can't by itself stop a double-send. Compare-and-set last_run_at from
// the exact value we read to `now`: Postgres row-locking serialises the
// two writers, so only the one whose WHERE still matches the old value
// flips the row and gets it back; the loser matches zero rows and skips.
// Cheaper than a Postgres advisory lock, and it closes the window for the
// whole batch execution, not just a single row.
const claimTs = now.toISOString()
const claimBase = supabase
.from('recurring_invoice_schedules')
.update({ last_run_at: claimTs })
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
const claimGated = schedule.last_run_at
? claimBase.eq('last_run_at', schedule.last_run_at)
: claimBase.is('last_run_at', null)
const { data: claimed, error: claimError } = await claimGated.select('id')
if (claimError) {
throw new Error(`failed to claim schedule for today: ${claimError.message}`)
}
if (!claimed || (claimed as unknown[]).length === 0) {
itemCtx.log.info('schedule claimed by a concurrent cron run; skipping')
results.push({
scheduleId: schedule.id,
skipped: true,
skipReason: 'claimed_by_concurrent_run',
})
return
}
// 5. Spawn the invoice. If it throws after we claimed, release the claim
// (restore the prior last_run_at) so a later cron retries today rather
// than treating the row as already run, and persist the failure as a
// user-visible warning: a deterministic error (bad VAT rate, missing
// items) fails every hourly retry and would otherwise skip the month
// silently via the stale roll-forward above. A later successful run
// overwrites the warning.
// Defence in depth (ASVS V2.3): the email chokepoint inside the schedule
// service enforces the sandbox rule on its own; the route additionally
// resolves it here and passes an explicit suppress flag, so the invariant
// does not hinge on a single check buried in a library function. The
// invoice is still generated as a draft (freeze-and-retain).
const suppressAutoSend = schedule.auto_send
? await isSandboxCompany(supabase, schedule.company_id)
: false
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
try {
result = await executeRecurringSchedule(supabase, schedule, now, { suppressAutoSend })
} catch (err) {
const reason = (err instanceof Error ? err.message : String(err)).slice(0, 300)
await supabase
.from('recurring_invoice_schedules')
.update({
last_run_at: schedule.last_run_at,
last_run_warning: `Körningen ${todayStockholm} misslyckades: ${reason}. Nytt försök görs automatiskt varje timme idag.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
.eq('last_run_at', claimTs)
throw err
}
const nextRunDate = computeNextRunDate(
stockholmToday,
schedule.day_of_month,
schedule.interval_months,
)
const { error: updateError } = await supabase
.from('recurring_invoice_schedules')
.update({
next_run_date: nextRunDate,
last_run_at: now.toISOString(),
last_invoice_id: result.invoiceId,
last_run_warning: result.warning,
generated_count: schedule.generated_count + 1,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
if (updateError) {
// The invoice exists. If we don't mark the schedule as ran, tomorrow's
// cron would spawn a duplicate. Surface this loudly.
itemCtx.log.error(
'invoice created but failed to update schedule: manual cleanup may be needed',
updateError,
{ scheduleId: schedule.id, invoiceId: result.invoiceId },
)
throw new Error(
`schedule update failed after invoice ${result.invoiceId} created: ${updateError.message}`,
)
}
results.push({
scheduleId: schedule.id,
invoiceId: result.invoiceId,
invoiceNumber: result.invoiceNumber,
autoSent: result.autoSent,
warning: result.warning,
})
})
ctx.log.info('recurring invoice cron summary', {
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
})
return NextResponse.json({
success: true,
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
failures: summary.failures,
results,
})
})
export const POST = GET