You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
81 lines
5.1 KiB
TypeScript
81 lines
5.1 KiB
TypeScript
// apps/hq/test/scheduler-recurring.test.ts
|
|
import { describe, it, expect } from 'vitest'
|
|
import { openDb } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { assignModule, createModule, setPrice } from '../src/repos-modules'
|
|
import { createRecurringPlan, getRecurringPlan } from '../src/repos-recurring'
|
|
import { saveAccount } from '../src/repos-email'
|
|
import { encrypt } from '../src/crypto'
|
|
import { listDocuments } from '../src/repos-documents'
|
|
import { listReminders, getReminder } from '../src/repos-reminders'
|
|
import { runDailyScan, type ScanDeps } from '../src/scheduler'
|
|
|
|
const KEY = '11'.repeat(32)
|
|
const okFetch = (async (url: string) =>
|
|
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'g1' }), { status: 200 })) as typeof fetch
|
|
const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch
|
|
|
|
function deps(f: typeof fetch): ScanDeps {
|
|
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' }
|
|
}
|
|
|
|
function world() {
|
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
|
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
|
|
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
|
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] })
|
|
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
|
|
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
|
|
return { db, c, m, cm }
|
|
}
|
|
|
|
describe('runDailyScan — recurring generation', () => {
|
|
it('auto plan: generates+issues an invoice, advances next_run one cadence, sends, marks sent', async () => {
|
|
const { db, c, cm } = world()
|
|
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
|
|
const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
|
|
expect(res.created['recurring_generated']).toBe(1)
|
|
expect(res.autoSent).toBe(1)
|
|
const invoices = listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents
|
|
expect(invoices).toHaveLength(1)
|
|
expect(invoices[0]!.docNo).toMatch(/^INV\//)
|
|
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // advanced exactly one month
|
|
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]!
|
|
expect(rem.status).toBe('sent')
|
|
expect(rem.docId).toBe(invoices[0]!.id)
|
|
})
|
|
it('failed send: invoice exists and next_run advanced, reminder is failed (queued for manual), no regeneration on re-run', async () => {
|
|
const { db, c, cm } = world()
|
|
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
|
|
const res = await runDailyScan(db, deps(deadFetch), '2026-07-10')
|
|
expect(res.created['recurring_generated']).toBe(1)
|
|
expect(res.autoFailed).toBe(1)
|
|
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(1) // invoice still issued
|
|
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // schedule advanced on generation
|
|
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]!
|
|
expect(rem.status).toBe('failed') // waits in the manual queue
|
|
// Re-run: next_run is now in the future → no second invoice, no duplicate reminder.
|
|
const again = await runDailyScan(db, deps(deadFetch), '2026-07-10')
|
|
expect(again.created['recurring_generated'] ?? 0).toBe(0)
|
|
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(1)
|
|
})
|
|
it('manual plan: generates the invoice but queues (no send)', async () => {
|
|
const { db, c, cm } = world()
|
|
createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'manual' })
|
|
const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
|
|
expect(res.autoSent).toBe(0)
|
|
expect(getReminder(db, listReminders(db, { ruleKind: 'recurring_generated' })[0]!.id)!.status).toBe('queued')
|
|
})
|
|
it('catch-up: a plan three months in arrears bills every missed month exactly once', async () => {
|
|
const { db, c, cm } = world()
|
|
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-05-10', policy: 'manual' })
|
|
const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
|
|
expect(res.created['recurring_generated']).toBe(3) // May, Jun, Jul
|
|
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(3)
|
|
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10')
|
|
const periods = listReminders(db, { ruleKind: 'recurring_generated' }).map((r) => r.duePeriod).sort()
|
|
expect(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10'])
|
|
})
|
|
})
|