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