Files
accounted/app/api/invoices/recurring/cron/route.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

264 lines
10 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,
computeInitialRunDate,
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 to next month, 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) {
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
// 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)
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