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 } /** * GST summary for filing (D26). One row per calendar month in range, over issued * INVOICE (output tax) and CREDIT_NOTE (which reduce output tax, so their tax counts * NEGATIVE — CGST Act s.34). Amounts are the stored, already-computed paise on each * document, so this matches the invoices exactly. Cancelled docs are excluded. */ export interface GstSummaryRow { period: string // 'YYYY-MM' invoiceCount: number; creditNoteCount: number taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number taxPaise: number; totalPaise: number } export async function gstSummary(db: DB, range?: DateRange): Promise<{ rows: GstSummaryRow[]; total: GstSummaryRow }> { let where = `WHERE doc_type IN ('INVOICE','CREDIT_NOTE') AND doc_no IS NOT NULL AND status <> 'cancelled'` const args: unknown[] = [] if (range?.from !== undefined) { where += ` AND doc_date >= ?`; args.push(range.from) } if (range?.to !== undefined) { where += ` AND doc_date <= ?`; args.push(range.to) } const docs = await db.all<{ doc_type: string; doc_date: string taxable_paise: number; cgst_paise: number; sgst_paise: number; igst_paise: number; payable_paise: number }>( `SELECT doc_type, doc_date, taxable_paise, cgst_paise, sgst_paise, igst_paise, payable_paise FROM document ${where}`, ...args, ) const acc = new Map() const blank = (period: string): GstSummaryRow => ({ period, invoiceCount: 0, creditNoteCount: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, taxPaise: 0, totalPaise: 0, }) const total = blank('TOTAL') for (const d of docs) { const period = d.doc_date.slice(0, 7) const sign = d.doc_type === 'CREDIT_NOTE' ? -1 : 1 const row = acc.get(period) ?? blank(period) if (sign === 1) { row.invoiceCount++; total.invoiceCount++ } else { row.creditNoteCount++; total.creditNoteCount++ } for (const [k, col] of [ ['taxablePaise', d.taxable_paise], ['cgstPaise', d.cgst_paise], ['sgstPaise', d.sgst_paise], ['igstPaise', d.igst_paise], ['totalPaise', d.payable_paise], ] as const) { row[k] += sign * col; total[k] += sign * col } acc.set(period, row) } for (const r of [...acc.values(), total]) r.taxPaise = r.cgstPaise + r.sgstPaise + r.igstPaise const rows = [...acc.values()].sort((a, b) => a.period.localeCompare(b.period)) return { rows, total } } /** 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. */ async function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): Promise { 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 (await db.all<{ id: string }>(sql, ...args)).map((r) => r.id) } /** Settled amount on an invoice, clamped to [0, payable]. */ async function settledOf(db: DB, docId: string, payablePaise: number): Promise { return Math.max(0, Math.min(payablePaise, payablePaise - await outstandingPaise(db, docId))) } export interface DuesAgingRow { clientId: string; clientName: string b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number } export async function duesAging(db: DB, today: string): Promise { // Buckets age from the stamped due date when present (D18 WS-A), doc date otherwise. const invoices = await db.all<{ id: string; client_id: string; anchor: string; client_name: string }>( `SELECT d.id, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, 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')`, ) const acc = new Map() for (const inv of invoices) { const out = await 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.anchor, 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 async function moduleRevenue(db: DB, range?: DateRange): Promise { const acc = new Map() for (const id of await invoiceIds(db, undefined, range)) { const inv = (await getDocument(db, id))! const settledTotal = await 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) }) } const out: ModuleRevenueRow[] = [] for (const [moduleId, v] of acc.entries()) { const mod = await getModule(db, moduleId) out.push({ moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId, billedPaise: v.billed, settledPaise: v.settled, }) } return out.sort((a, b) => b.billedPaise - a.billedPaise) } export interface ProfitabilityRow { clientId: string; clientName: string billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number } export async function clientProfitability(db: DB, range?: DateRange): Promise { const clients = await db.all<{ id: string; name: string }>(`SELECT id, name FROM client ORDER BY name`) const out: ProfitabilityRow[] = [] for (const c of clients) { let billed = 0 let settled = 0 for (const id of await invoiceIds(db, c.id, range)) { const inv = (await getDocument(db, id))! billed += inv.payablePaise settled += await settledOf(db, inv.id, inv.payablePaise) } const awsCostPaise = await 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) }