feat(d26): functional gaps — GST summary, doc search, bulk ops (backend)

- gstSummary(range): per-month GST filing summary over issued INVOICE (adds) +
  CREDIT_NOTE (subtracts, s.34), CGST/SGST/IGST split from stored paise, with a
  grand total. Route GET /reports/gst-summary. Money locked in tests.
- listDocuments gains free-text q (doc number OR client name, case-insensitive
  both engines); GET /documents ?q=.
- bulkUpdateClients (owner) + bulkSetMilestone (owner) for cleanup at scale —
  POST /clients/bulk, POST /projects/bulk-milestone; each client keeps its own
  audit; milestone bulk writes one summary audit. Suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent bbe125892d
commit 009179d8df

@ -3,7 +3,7 @@ import { fyOf, validateGstin } from '@sims/domain'
import { isManagerial, login, requireAuth, requireManagerial, requireOwner } from './auth' import { isManagerial, login, requireAuth, requireManagerial, requireOwner } from './auth'
import type { DB } from './db' import type { DB } from './db'
import { import {
createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword, bulkUpdateClients, createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword,
setClientOwner, updateClient, setClientOwner, updateClient,
type Client, type ClientInput, type ClientPatch, type Client, type ClientInput, type ClientPatch,
} from './repos-clients' } from './repos-clients'
@ -58,11 +58,11 @@ import {
costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
} from './repos-aws' } from './repos-aws'
import { pullMonthlyCosts } from './aws-costs' import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports'
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { import {
addProjectMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
projectsPendingMilestone, setMilestone, projectsPendingMilestone, setMilestone,
} from './repos-milestones' } from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex' import { commitImport, stageCsv, verificationReport } from './import-apex'
@ -873,6 +873,7 @@ export function apiRouter(
if (typeof req.query['type'] === 'string') filter.type = req.query['type'] if (typeof req.query['type'] === 'string') filter.type = req.query['type']
if (typeof req.query['status'] === 'string') filter.status = req.query['status'] if (typeof req.query['status'] === 'string') filter.status = req.query['status']
if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId'] if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId']
if (typeof req.query['q'] === 'string' && req.query['q'] !== '') filter.q = req.query['q']
const page = Number(req.query['page']) const page = Number(req.query['page'])
if (Number.isInteger(page) && page >= 1) filter.page = page if (Number.isInteger(page) && page >= 1) filter.page = page
const pageSize = Number(req.query['pageSize']) const pageSize = Number(req.query['pageSize'])
@ -1501,6 +1502,41 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
} }
}) })
// D26: GST filing summary (per month; invoices add, credit notes subtract).
r.get('/reports/gst-summary', requireAuth, async (req, res) => {
try {
res.json({ ok: true, ...await gstSummary(db, rangeOf(req)) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- bulk operations (D26 — cleanup at scale, owner-only) ----------
r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { ids?: unknown; patch?: unknown }
if (!Array.isArray(body.ids) || typeof body.patch !== 'object' || body.patch === null) {
throw new Error('Provide ids [] and a patch {}')
}
const out = await bulkUpdateClients(db, staffId(res), body.ids as string[], body.patch as ClientPatch)
res.json({ ok: true, ...out })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/projects/bulk-milestone', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { clientModuleIds?: unknown; key?: unknown; done?: unknown; doneOn?: unknown }
if (!Array.isArray(body.clientModuleIds) || typeof body.key !== 'string' || typeof body.done !== 'boolean') {
throw new Error('Provide clientModuleIds [], key, done')
}
const out = await bulkSetMilestone(db, staffId(res), body.clientModuleIds as string[], body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined)
res.json({ ok: true, ...out })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- company profile (owner-editable; PDFs read these live) ---------- // ---------- company profile (owner-editable; PDFs read these live) ----------
// D18: the composer autofills invoice due dates from this (issue also stamps with it). // D18: the composer autofills invoice due dates from this (issue also stamps with it).

@ -175,6 +175,26 @@ export async function revealClientDbPassword(db: DB, userId: string, id: string,
return plain return plain
} }
/**
* Bulk-apply the same patch to many clients (D26) for cleanup like filling GSTIN /
* state / sector across a set. One transaction; each client keeps its own audited
* before/after via updateClient (traceable). Returns how many were updated.
*/
export async function bulkUpdateClients(
db: DB, userId: string, ids: string[], patch: ClientPatch,
): Promise<{ updated: number }> {
if (ids.length === 0) return { updated: 0 }
return db.transaction(async () => {
let updated = 0
for (const id of ids) {
if ((await getClient(db, id)) === null) continue
await updateClient(db, userId, id, patch)
updated++
}
return { updated }
})
}
/** /**
* Assign (or clear, with null) the account/enquiry owner. Deliberately NOT part of * Assign (or clear, with null) the account/enquiry owner. Deliberately NOT part of
* updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while * updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while

@ -71,6 +71,8 @@ export async function getDocument(db: DB, id: string): Promise<Doc | null> {
export interface DocumentFilter { export interface DocumentFilter {
type?: string; status?: string; clientId?: string type?: string; status?: string; clientId?: string
/** Free-text search over document number and client name (D26). */
q?: string
/** 1-based page (floor 1). */ /** 1-based page (floor 1). */
page?: number page?: number
/** Rows per page — default 50, hard cap 200. */ /** Rows per page — default 50, hard cap 200. */
@ -91,8 +93,14 @@ export async function listDocuments(db: DB, filter: DocumentFilter = {}): Promis
if (filter.type !== undefined) { where += ` AND d.doc_type=?`; args.push(filter.type) } if (filter.type !== undefined) { where += ` AND d.doc_type=?`; args.push(filter.type) }
if (filter.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) } if (filter.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) }
if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) } if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) }
// D26: free-text search on document number or client name (case-insensitive, both engines).
if (filter.q !== undefined && filter.q !== '') {
where += ` AND (LOWER(d.doc_no) LIKE LOWER(?) OR LOWER(c.name) LIKE LOWER(?))`
const like = `%${filter.q}%`
args.push(like, like)
}
const total = (await db.get<{ n: number }>( const total = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM document d${where}`, ...args, `SELECT COUNT(*) AS n FROM document d JOIN client c ON c.id = d.client_id${where}`, ...args,
))!.n ))!.n
const rows = await db.all<DocRow & { client_name: string }>( const rows = await db.all<DocRow & { client_name: string }>(
`SELECT d.*, c.name AS client_name `SELECT d.*, c.name AS client_name

@ -111,6 +111,34 @@ export async function addProjectMilestone(
}) })
} }
/**
* Bulk-tick one milestone across many projects at once (D26) for onboarding cleanup
* (e.g. mark the already-live services 'installed' + 'go-live'). One transaction; a
* single summary audit row. Only touches projects that actually have the milestone.
*/
export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string,
): Promise<{ updated: number }> {
if (clientModuleIds.length === 0) return { updated: 0 }
if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) {
throw new Error('doneOn must be YYYY-MM-DD')
}
const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
return db.transaction(async () => {
let updated = 0
for (const id of clientModuleIds) {
const r = await db.run(
`UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, id, key,
)
updated += r.changes
}
await writeAudit(db, userId, 'bulk_set_milestone', 'project_milestone', '*', undefined,
{ key, done, doneOn: stamp, projects: clientModuleIds.length, updated })
return { updated }
})
}
// ---------- cross-project reporting (the board) ---------- // ---------- cross-project reporting (the board) ----------
export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number }

@ -11,6 +11,55 @@ import { awsCostForClient } from './repos-aws'
export interface DateRange { from?: string; to?: string } 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. */ /** Whole-day difference on YYYY-MM-DD (UTC), lexical-safe like scheduler.addDaysIso. */
function daysBetween(fromIso: string, toIso: string): number { function daysBetween(fromIso: string, toIso: string): number {
return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)

@ -0,0 +1,84 @@
// apps/hq/test/functional-d26.test.ts — D26: GST summary, document search, bulk ops.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient, bulkUpdateClients, getClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft, issueDocument, createCreditNote, listDocuments, getDocument } from '../src/repos-documents'
import { gstSummary } from '../src/repos-reports'
import { listProjectMilestones, bulkSetMilestone } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company state 32, GST18
const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core', allowedKinds: ['yearly'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
const mkInvoice = async (db: any, c: any, m: any, date: string) => {
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id)
await db.run(`UPDATE document SET doc_date=? WHERE id=?`, date, inv.id)
return inv
}
describe('GST summary (D26, money locked)', () => {
it('sums invoices per month; credit notes subtract; CGST+SGST split exact', async () => {
const { db, c, m } = await world()
await mkInvoice(db, c, m, '2026-07-05') // ₹10,000 + 18% = 11,800; CGST 900 SGST 900
const inv2 = await mkInvoice(db, c, m, '2026-07-20')
await mkInvoice(db, c, m, '2026-08-01')
// A full, ISSUED credit note against inv2 (same month) reverses one invoice's tax.
const cnDraft = await createCreditNote(db, 'u1', inv2.id)
await issueDocument(db, 'u1', cnDraft.id) // a CN counts for GST only once numbered
await db.run(`UPDATE document SET doc_date='2026-07-25' WHERE id=?`, cnDraft.id)
void getDocument
const { rows, total } = await gstSummary(db, { from: '2026-07-01', to: '2026-08-31' })
const jul = rows.find((r) => r.period === '2026-07')!
expect(jul).toMatchObject({ invoiceCount: 2, creditNoteCount: 1 })
// 2 invoices 1 credit note = net 1 invoice's worth in July.
expect(jul.taxablePaise).toBe(10_000_00)
expect(jul.cgstPaise).toBe(900_00)
expect(jul.sgstPaise).toBe(900_00)
expect(jul.taxPaise).toBe(1_800_00)
expect(jul.totalPaise).toBe(11_800_00)
const aug = rows.find((r) => r.period === '2026-08')!
expect(aug.totalPaise).toBe(11_800_00)
expect(total.totalPaise).toBe(23_600_00) // net July (1) + Aug (1)
expect(total.taxPaise).toBe(3_600_00)
})
})
describe('document search (D26)', () => {
it('finds documents by number and by client name, case-insensitive', async () => {
const { db, c, m } = await world()
const inv = await mkInvoice(db, c, m, '2026-07-05')
expect((await listDocuments(db, { q: inv.docNo!.toLowerCase() })).total).toBe(1)
expect((await listDocuments(db, { q: 'KARAPUZHA' })).total).toBe(1)
expect((await listDocuments(db, { q: 'nomatch' })).total).toBe(0)
expect((await listDocuments(db, {})).total).toBe(1) // no q = all
})
})
describe('bulk operations (D26)', () => {
it('bulk-updates client fields in one call, each audited', async () => {
const { db } = await world()
const a = await createClient(db, 'u1', { name: 'A', stateCode: '32' })
const b = await createClient(db, 'u1', { name: 'B', stateCode: '32' })
const out = await bulkUpdateClients(db, 'u1', [a.id, b.id], { district: 'Ernakulam' })
expect(out.updated).toBe(2)
expect((await getClient(db, a.id))!.district).toBe('Ernakulam')
expect((await getClient(db, b.id))!.district).toBe('Ernakulam')
})
it('bulk-ticks a milestone across projects', async () => {
const { db, c, m } = await world()
const p = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const out = await bulkSetMilestone(db, 'u1', [p.id], 'installed', true, '2026-07-10')
expect(out.updated).toBe(1)
const ms = await listProjectMilestones(db, p.id)
expect(ms.find((x) => x.key === 'installed')).toMatchObject({ done: true, doneOn: '2026-07-10' })
})
})
Loading…
Cancel
Save