|
|
import type { DB } from './db'
|
|
|
import { getDocument } from './repos-documents'
|
|
|
import { getModule } from './repos-modules'
|
|
|
import { outstandingPaise, splitProRata } from './repos-payments'
|
|
|
import { awsCostForClient } from './repos-aws'
|
|
|
|
|
|
/** Read-only reporting aggregations (D12 plain-repo pattern). Settlement per
|
|
|
* invoice is payable − outstandingPaise (= allocations + credit-notes), clamped
|
|
|
* to [0, payable] — identical to modulePaidView, so a paisa split here matches
|
|
|
* a paisa split there. Nothing here mutates. */
|
|
|
|
|
|
export interface DateRange { from?: string; to?: string }
|
|
|
|
|
|
/** Whole-day difference on YYYY-MM-DD (UTC), lexical-safe like scheduler.addDaysIso. */
|
|
|
function daysBetween(fromIso: string, toIso: string): number {
|
|
|
return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)
|
|
|
}
|
|
|
|
|
|
/** Issued, non-cancelled invoices, optionally within a doc_date range. */
|
|
|
function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): string[] {
|
|
|
let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'`
|
|
|
const args: unknown[] = []
|
|
|
if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) }
|
|
|
if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) }
|
|
|
if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) }
|
|
|
sql += ` ORDER BY doc_date, doc_no`
|
|
|
return (db.prepare(sql).all(...args) as { id: string }[]).map((r) => r.id)
|
|
|
}
|
|
|
|
|
|
/** Settled amount on an invoice, clamped to [0, payable]. */
|
|
|
function settledOf(db: DB, docId: string, payablePaise: number): number {
|
|
|
return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId)))
|
|
|
}
|
|
|
|
|
|
export interface DuesAgingRow {
|
|
|
clientId: string; clientName: string
|
|
|
b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number
|
|
|
}
|
|
|
|
|
|
export function duesAging(db: DB, today: string): DuesAgingRow[] {
|
|
|
const invoices = db.prepare(
|
|
|
`SELECT d.id, d.client_id, d.doc_date, c.name AS client_name
|
|
|
FROM document d JOIN client c ON c.id = d.client_id
|
|
|
WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`,
|
|
|
).all() as { id: string; client_id: string; doc_date: string; client_name: string }[]
|
|
|
const acc = new Map<string, DuesAgingRow>()
|
|
|
for (const inv of invoices) {
|
|
|
const out = outstandingPaise(db, inv.id)
|
|
|
if (out <= 0) continue
|
|
|
const row = acc.get(inv.client_id)
|
|
|
?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 }
|
|
|
const age = daysBetween(inv.doc_date, today)
|
|
|
if (age <= 30) row.b0_30 += out
|
|
|
else if (age <= 60) row.b31_60 += out
|
|
|
else if (age <= 90) row.b61_90 += out
|
|
|
else row.b90p += out
|
|
|
row.totalPaise += out
|
|
|
acc.set(inv.client_id, row)
|
|
|
}
|
|
|
return [...acc.values()].sort((a, b) => b.totalPaise - a.totalPaise)
|
|
|
}
|
|
|
|
|
|
export interface ModuleRevenueRow {
|
|
|
moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number
|
|
|
}
|
|
|
|
|
|
export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] {
|
|
|
const acc = new Map<string, { billed: number; settled: number }>()
|
|
|
for (const id of invoiceIds(db, undefined, range)) {
|
|
|
const inv = getDocument(db, id)!
|
|
|
const settledTotal = settledOf(db, inv.id, inv.payablePaise)
|
|
|
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
|
|
|
inv.payload.lines.forEach((line, i) => {
|
|
|
const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 }
|
|
|
e.billed += line.lineTotalPaise
|
|
|
e.settled += shares[i]!
|
|
|
acc.set(line.itemId, e)
|
|
|
})
|
|
|
}
|
|
|
return [...acc.entries()].map(([moduleId, v]) => {
|
|
|
const mod = getModule(db, moduleId)
|
|
|
return {
|
|
|
moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId,
|
|
|
billedPaise: v.billed, settledPaise: v.settled,
|
|
|
}
|
|
|
}).sort((a, b) => b.billedPaise - a.billedPaise)
|
|
|
}
|
|
|
|
|
|
export interface ProfitabilityRow {
|
|
|
clientId: string; clientName: string
|
|
|
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
|
|
|
}
|
|
|
|
|
|
export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] {
|
|
|
const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[]
|
|
|
const out: ProfitabilityRow[] = []
|
|
|
for (const c of clients) {
|
|
|
let billed = 0
|
|
|
let settled = 0
|
|
|
for (const id of invoiceIds(db, c.id, range)) {
|
|
|
const inv = getDocument(db, id)!
|
|
|
billed += inv.payablePaise
|
|
|
settled += settledOf(db, inv.id, inv.payablePaise)
|
|
|
}
|
|
|
const awsCostPaise = awsCostForClient(db, c.id, range?.from, range?.to)
|
|
|
if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients
|
|
|
out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise })
|
|
|
}
|
|
|
return out.sort((a, b) => b.marginPaise - a.marginPaise)
|
|
|
}
|