Files
accounted/app/api/invoices/recurring/cron/__tests__/route.test.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

262 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
// The queued mock's proxy chain discards call arguments, but these tests need
// to assert exactly what the cron writes back to the schedule row (claim
// release + failure warning, stale roll-forward warning). Wrap .from() so
// every .update() payload is recorded before delegating to the queue chain.
const updatePayloads: Array<{ table: string; payload: Record<string, unknown> }> = []
const baseFrom = mockSupabase.from.getMockImplementation()!
mockSupabase.from.mockImplementation((table: string) => {
const chain = baseFrom(table) as Record<string, (...args: unknown[]) => unknown>
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'update') {
return (payload: Record<string, unknown>) => {
updatePayloads.push({ table, payload })
return target.update(payload)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockSupabase,
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Cron auth always passes in these tests.
vi.mock('@/lib/auth/cron', () => ({ verifyCronSecret: () => null }))
// Replace only the heavy invoice-spawning function; keep the real date helpers
// (getStockholmDateHour / computeNextRunDate / computeInitialRunDate).
const executeRecurringSchedule = vi.fn()
vi.mock('@/lib/invoices/recurring-schedule-service', async (importActual) => {
const actual = await importActual<typeof import('@/lib/invoices/recurring-schedule-service')>()
return {
...actual,
executeRecurringSchedule: (...args: unknown[]) => executeRecurringSchedule(...args),
}
})
// Route-level sandbox resolution (defence in depth, ASVS V2.3).
const isSandboxCompany = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => isSandboxCompany(...args),
}))
import { GET } from '../route'
type ResultRow = {
scheduleId: string
invoiceId?: string
skipped?: boolean
skipReason?: string
}
type CronBody = { success: boolean; succeeded: number; results: ResultRow[] }
function makeSchedule(overrides: Record<string, unknown> = {}) {
return {
id: 's-1',
company_id: 'c-1',
day_of_month: 6,
interval_months: 1,
send_hour: 8,
next_run_date: '2026-07-06',
last_run_at: null,
generated_count: 0,
items: [],
...overrides,
}
}
const req = () => createMockRequest('/api/invoices/recurring/cron', { method: 'GET' })
describe('GET /api/invoices/recurring/cron', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
updatePayloads.length = 0
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('sends a schedule due today once the Stockholm send hour has arrived', async () => {
// 08:30 UTC = 10:30 Stockholm (CEST) -> hour 10 >= send_hour 8
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
// Atomic claim wins (returns the row it flipped).
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: true,
warning: null,
})
const { status, body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(status).toBe(200)
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
expect(body.succeeded).toBe(1)
expect(body.results[0].invoiceId).toBe('inv-1')
// No auto_send on the schedule -> no sandbox lookup, no suppression.
expect(isSandboxCompany).not.toHaveBeenCalled()
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: false })
})
it('resolves the sandbox flag at the route level and suppresses auto-send for sandbox companies', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8, auto_send: true })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
isSandboxCompany.mockResolvedValue(true)
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: false,
warning: 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.',
})
const { status } = await parseJsonResponse<CronBody>(await GET(req()))
expect(status).toBe(200)
// Defence in depth: the route resolved the sandbox state itself and told
// the service explicitly, instead of relying only on the chokepoint
// inside sendInvoiceFromSchedule.
expect(isSandboxCompany).toHaveBeenCalledWith(expect.anything(), 'c-1')
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: true })
})
it('skips when a concurrent cron run already claimed the schedule', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
// Atomic claim loses the race: the compare-and-set matched zero rows.
enqueue({ data: [], error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('claimed_by_concurrent_run')
})
it('does not send before the chosen Stockholm hour', async () => {
// 04:30 UTC = 06:30 Stockholm -> hour 6 < send_hour 8
vi.setSystemTime(new Date('2026-07-06T04:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8, next_run_date: '2026-07-06' })], error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('hour_not_reached')
})
it('rolls a past-due schedule forward WITHOUT sending (never invoices the past)', async () => {
// Today Stockholm = 2026-07-06; schedule missed its 2026-07-05 date.
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
})
it('releases the claim AND persists a failure warning when execution throws', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockRejectedValue(new Error('VAT rate 25% not allowed'))
// Claim release + warning write.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody & { failed: number }>(await GET(req()))
expect(body.failed).toBe(1)
// The release update must restore the pre-claim last_run_at (null here)
// so a later cron retries today, and carry a user-visible warning so a
// deterministic failure never skips the month silently.
const release = updatePayloads.find(
(u) => u.table === 'recurring_invoice_schedules' && 'last_run_warning' in u.payload,
)
expect(release).toBeDefined()
expect(release!.payload.last_run_at).toBeNull()
expect(release!.payload.last_run_warning).toContain('2026-07-06 misslyckades')
expect(release!.payload.last_run_warning).toContain('VAT rate 25% not allowed')
})
it('writes a skip warning when rolling a stale schedule forward', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
// Roll-forward update.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
expect(roll).toBeDefined()
expect(roll!.payload.next_run_date).toBe('2026-08-05')
expect(roll!.payload.last_run_warning).toContain('Ingen faktura skapades den 2026-07-05')
expect(roll!.payload.last_run_warning).toContain('2026-08-05')
})
it('advances a quarterly schedule one quarter after a successful run', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ interval_months: 3 })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: true,
warning: null,
})
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(body.succeeded).toBe(1)
const bump = updatePayloads.find(
(u) => u.table === 'recurring_invoice_schedules' && 'next_run_date' in u.payload,
)
expect(bump).toBeDefined()
expect(bump!.payload.next_run_date).toBe('2026-10-06')
})
it('rolls a stale quarterly schedule forward on its own quarter grid', async () => {
// Quarterly Jan/Apr/Jul/Oct schedule missed Apr 5 (long outage or pause);
// today is Jul 6. The next slot on the grid is Oct 5 (Jul 5 already
// passed), NOT Aug 5 as a today-anchored monthly roll would give.
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
data: [makeSchedule({ next_run_date: '2026-04-05', day_of_month: 5, interval_months: 3 })],
error: null,
})
// Roll-forward update.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
expect(roll).toBeDefined()
expect(roll!.payload.next_run_date).toBe('2026-10-05')
})
it('skips a schedule that already ran earlier today', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
data: [makeSchedule({ last_run_at: '2026-07-06T06:15:00Z', send_hour: 8 })],
error: null,
})
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('already_ran_today')
})
})