* 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
264 lines
10 KiB
TypeScript
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
|