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.
141 lines
7.6 KiB
TypeScript
141 lines
7.6 KiB
TypeScript
// apps/hq/test/due-dates.test.ts — go-live cluster WS-A: invoice due dates (D18)
|
|
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 } from '../src/repos-recurring'
|
|
import { convertDocument, createDraft, getDocument, issueDocument, listDocuments } from '../src/repos-documents'
|
|
import { getReminder, listReminders, setSetting, upsertReminder } from '../src/repos-reminders'
|
|
import { reminderContext } from '../src/send-reminder'
|
|
import { reminderEmail } from '../src/reminder-templates'
|
|
import { runDailyScan, type ScanDeps } from '../src/scheduler'
|
|
|
|
const deps: ScanDeps = {
|
|
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
|
|
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
|
|
now: () => '2026-07-17T00:00:00Z',
|
|
}
|
|
|
|
function addDaysIso(iso: string, days: number): string {
|
|
const d = new Date(`${iso}T00:00:00Z`)
|
|
d.setUTCDate(d.getUTCDate() + days)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
async function world() {
|
|
const db = openDb(':memory:'); await seedIfEmpty(db) // seeds billing.payment_terms_days=15
|
|
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
|
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
|
const invoiceDraft = () => createDraft(db, 'u1', {
|
|
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})
|
|
return { db, c, m, invoiceDraft }
|
|
}
|
|
|
|
describe('due dates (D18)', () => {
|
|
it('issue stamps doc_date + terms on an invoice without one (manual issue path)', async () => {
|
|
const { db, invoiceDraft } = await world()
|
|
const draft = await invoiceDraft()
|
|
expect(draft.dueDate).toBeNull()
|
|
await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
|
|
const inv = await issueDocument(db, 'u1', draft.id)
|
|
expect(inv.dueDate).toBe('2026-07-16') // 2026-07-01 + 15
|
|
})
|
|
|
|
it('a caller-provided due date survives issue untouched', async () => {
|
|
const { db, c, m } = await world()
|
|
const draft = await createDraft(db, 'u1', {
|
|
docType: 'INVOICE', clientId: c.id, dueDate: '2026-09-30',
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})
|
|
expect(draft.dueDate).toBe('2026-09-30')
|
|
expect((await issueDocument(db, 'u1', draft.id)).dueDate).toBe('2026-09-30')
|
|
})
|
|
|
|
it('the terms setting is honoured when changed (dated behavior lives in the stamp)', async () => {
|
|
const { db, invoiceDraft } = await world()
|
|
await setSetting(db, 'u1', 'billing.payment_terms_days', '30')
|
|
const draft = await invoiceDraft()
|
|
await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
|
|
expect((await issueDocument(db, 'u1', draft.id)).dueDate).toBe('2026-07-31')
|
|
})
|
|
|
|
it('quotes and proformas never get a due date — at draft or at issue', async () => {
|
|
const { db, c, m } = await world()
|
|
await expect(createDraft(db, 'u1', {
|
|
docType: 'QUOTATION', clientId: c.id, dueDate: '2026-08-01',
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})).rejects.toThrow(/INVOICE/)
|
|
const pi = await createDraft(db, 'u1', {
|
|
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})
|
|
expect((await issueDocument(db, 'u1', pi.id)).dueDate).toBeNull()
|
|
})
|
|
|
|
it('proforma → invoice conversion gets stamped at issue (convert-and-send path)', async () => {
|
|
const { db, c, m } = await world()
|
|
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
|
|
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})).id)
|
|
const invDraft = await convertDocument(db, 'u1', pi.id, 'INVOICE')
|
|
const inv = await issueDocument(db, 'u1', invDraft.id)
|
|
expect(inv.dueDate).not.toBeNull() // stamped from terms on the invoice's own doc_date
|
|
})
|
|
|
|
it('recurring generation stamps the due date on the invoice it issues (third issue path)', async () => {
|
|
const { db, c, m } = await world()
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
|
|
await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-17', policy: 'manual' })
|
|
await runDailyScan(db, deps, '2026-07-17')
|
|
const [inv] = (await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents
|
|
expect(inv).toBeDefined()
|
|
expect(inv!.dueDate).toBe(addDaysIso(inv!.docDate, 15)) // doc_date + seeded 15-day terms
|
|
})
|
|
|
|
it('bad dueDate format is rejected at draft', async () => {
|
|
const { db, c, m } = await world()
|
|
await expect(createDraft(db, 'u1', {
|
|
docType: 'INVOICE', clientId: c.id, dueDate: '17-07-2026',
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})).rejects.toThrow(/YYYY-MM-DD/)
|
|
})
|
|
|
|
it('overdue scan anchors on the due date: not yet due ⇒ silent, past due ⇒ milestone from due_date', async () => {
|
|
const { db, invoiceDraft } = await world()
|
|
// Old issue date but due date in the future → NOT overdue.
|
|
const a = await issueDocument(db, 'u1', (await invoiceDraft()).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-05-01', due_date='2026-08-01' WHERE id=?`, a.id)
|
|
// Due 16 days ago → d15 milestone (7/15/30 ladder), counted from due_date not doc_date.
|
|
const b = await issueDocument(db, 'u1', (await invoiceDraft()).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-05-01', due_date='2026-07-01' WHERE id=?`, b.id)
|
|
await runDailyScan(db, deps, '2026-07-17')
|
|
const rows = (await listReminders(db, {})).filter((r) => r.ruleKind === 'invoice_overdue')
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]).toMatchObject({ subjectId: b.id, duePeriod: 'd15' })
|
|
})
|
|
|
|
it('the reminder email says "was due on X and is now N day(s) overdue"', async () => {
|
|
const { db, c, invoiceDraft } = await world()
|
|
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
|
|
await db.run(`UPDATE document SET due_date='2026-07-02' WHERE id=?`, inv.id)
|
|
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
|
|
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
|
|
expect(ctx.dueDate).toBe('2026-07-02')
|
|
expect(ctx.daysOverdue).toBe(15)
|
|
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/was due on 2026-07-02 and is now 15 day\(s\) overdue/)
|
|
})
|
|
|
|
it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', async () => {
|
|
const { db, c, invoiceDraft } = await world()
|
|
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`, inv.id)
|
|
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd7', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
|
|
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
|
|
expect(ctx.dueDate).toBeUndefined()
|
|
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/is outstanding and is now 10 day\(s\) past due/)
|
|
})
|
|
})
|