diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 1e671f4..496d4bd 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -23,6 +23,10 @@ import { createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, } from './repos-recurring' +import { + amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc, + updateAmc, type AmcPatch, type CreateAmcInput, +} from './repos-amc' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -325,6 +329,49 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) }) }) + // ---------- amc ---------- + r.get('/clients/:id/amc', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) })) + res.json({ ok: true, contracts }) + }) + r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const amc = createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id }) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + try { + const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + const amc = deactivateAmc(db, staffId(res), id) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + }) + r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + try { + res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/repos-amc.ts b/apps/hq/src/repos-amc.ts new file mode 100644 index 0000000..cd44953 --- /dev/null +++ b/apps/hq/src/repos-amc.ts @@ -0,0 +1,145 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' +import { createDraft, getDocument, issueDocument, type Doc } from './repos-documents' + +/** AMC contracts — plain functions over the handle (D12 pattern). Paid state is + * derived from the linked invoice's settlement; legacy_paid is a manual flag for + * imported contracts only, so there is one source of truth and no drift. */ + +export interface AmcContract { + id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string + amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null + invoiceDocId: string | null; active: boolean +} + +interface AmcRow { + id: string; client_id: string; coverage: string; period_from: string; period_to: string + amount_paise: number; renewal_reminder_days: number; legacy_paid: number | null + invoice_doc_id: string | null; active: number +} + +function toAmc(r: AmcRow): AmcContract { + return { + id: r.id, clientId: r.client_id, coverage: r.coverage, + periodFrom: r.period_from, periodTo: r.period_to, amountPaise: r.amount_paise, + renewalReminderDays: r.renewal_reminder_days, + legacyPaid: r.legacy_paid === null ? null : r.legacy_paid === 1, + invoiceDocId: r.invoice_doc_id, active: r.active === 1, + } +} + +export function getAmc(db: DB, id: string): AmcContract | null { + const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined + return row === undefined ? null : toAmc(row) +} + +export function listAmc(db: DB, clientId: string): AmcContract[] { + const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[] + return rows.map(toAmc) +} + +export interface CreateAmcInput { + clientId: string; coverage?: string; periodFrom: string; periodTo: string + amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean +} + +export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) { + throw new Error('amountPaise must be a non-negative integer (paise)') + } + const id = uuidv7() + db.prepare( + `INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise, + renewal_reminder_days, legacy_paid, invoice_doc_id, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`, + ).run( + id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise, + input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0, + ) + const amc = getAmc(db, id)! + writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc) + return amc +} + +export interface AmcPatch { + coverage?: string; periodFrom?: string; periodTo?: string; amountPaise?: number + renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean +} + +export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract { + const before = getAmc(db, id) + if (before === null) throw new Error('AMC contract not found') + const sets: string[] = [] + const args: unknown[] = [] + if (patch.coverage !== undefined) { sets.push('coverage=?'); args.push(patch.coverage) } + if (patch.periodFrom !== undefined) { sets.push('period_from=?'); args.push(patch.periodFrom) } + if (patch.periodTo !== undefined) { sets.push('period_to=?'); args.push(patch.periodTo) } + if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) } + if (patch.renewalReminderDays !== undefined) { sets.push('renewal_reminder_days=?'); args.push(patch.renewalReminderDays) } + if (patch.legacyPaid !== undefined) { sets.push('legacy_paid=?'); args.push(patch.legacyPaid === null ? null : patch.legacyPaid ? 1 : 0) } + if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getAmc(db, id)! + writeAudit(db, userId, 'update', 'amc_contract', id, before, after) + return after +} + +export function deactivateAmc(db: DB, userId: string, id: string): AmcContract { + return updateAmc(db, userId, id, { active: false }) +} + +/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the + * contract amount, issued and linked back via invoice_doc_id. */ +export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc { + const amc = getAmc(db, amcId) + if (amc === null) throw new Error('AMC contract not found') + if (amc.invoiceDocId !== null) { + const existing = getDocument(db, amc.invoiceDocId) + if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) { + throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`) + } + } + const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined + if (amcModule === undefined) throw new Error('AMC module missing — run the seed') + return db.transaction(() => { + const draft = createDraft(db, userId, { + docType: 'INVOICE', clientId: amc.clientId, + lines: [{ + moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance', + qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise, + }], + }) + const inv = issueDocument(db, userId, draft.id) + db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId) + writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id }) + return inv + })() +} + +/** Local outstanding read (payable − allocations − non-cancelled credit notes) — + * mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can + * switch to it then, but does not depend on Task 7 to compile. */ +function outstandingOf(db: DB, docId: string): number { + const doc = getDocument(db, docId) + if (doc === null) return 0 + const alloc = (db.prepare( + `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, + ).get(docId) as { total: number }).total + const credited = (db.prepare( + `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document + WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, + ).get(docId) as { total: number }).total + return doc.payablePaise - alloc - credited +} + +export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' { + if (amc.legacyPaid === true) return 'paid' + if (amc.invoiceDocId === null) return 'unbilled' + return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid' +} diff --git a/apps/hq/test/amc.test.ts b/apps/hq/test/amc.test.ts new file mode 100644 index 0000000..9b8b805 --- /dev/null +++ b/apps/hq/test/amc.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { recordPayment } from '../src/repos-payments' +import { + createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc, +} from '../src/repos-amc' + +function setup() { + const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + return { db, c } +} + +describe('amc contracts', () => { + it('creates, generates a renewal invoice, and derives paid from settlement', () => { + const { db, c } = setup() + const amc = createAmc(db, 'u1', { + clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31', + amountPaise: 20_000_00, + }) + expect(listAmc(db, c.id)).toHaveLength(1) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled') + const inv = generateAmcRenewalInvoice(db, 'u1', amc.id) + expect(inv.docType).toBe('INVOICE') + expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 }) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') + }) + it('honours the legacy_paid flag for imported contracts without an invoice', () => { + const { db, c } = setup() + const amc = createAmc(db, 'u1', { + clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true, + }) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') + }) +})