You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-reports.ts

163 lines
7.6 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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