|
|
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<string, GstSummaryRow>()
|
|
|
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<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 (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<number> {
|
|
|
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<DuesAgingRow[]> {
|
|
|
// 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<string, DuesAgingRow>()
|
|
|
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<ModuleRevenueRow[]> {
|
|
|
const acc = new Map<string, { billed: number; settled: number }>()
|
|
|
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<ProfitabilityRow[]> {
|
|
|
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)
|
|
|
}
|