feat(hq): amc contracts with renewal invoice and derived paid state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 405ee2917f
commit 0e66bd8ff8

@ -23,6 +23,10 @@ import {
createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans,
updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, updateRecurringPlan, type CreateRecurringInput, type RecurringPatch,
} from './repos-recurring' } from './repos-recurring'
import {
amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc,
updateAmc, type AmcPatch, type CreateAmcInput,
} from './repos-amc'
import { documentHtml } from './templates' import { documentHtml } from './templates'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { emailStatus } from './repos-email' import { emailStatus } from './repos-email'
@ -325,6 +329,49 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) }) 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<CreateAmcInput, 'clientId'>), 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 ---------- // ---------- payments ----------
r.post('/payments', requireAuth, (req, res) => { r.post('/payments', requireAuth, (req, res) => {
try { try {

@ -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'
}

@ -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')
})
})
Loading…
Cancel
Save