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