* 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>
262 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|