diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index c5c0c04..e506c37 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -3,7 +3,7 @@ import { fyOf, validateGstin } from '@sims/domain' import { isManagerial, login, requireAuth, requireManagerial, requireOwner } from './auth' import type { DB } from './db' import { - createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword, + bulkUpdateClients, createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword, setClientOwner, updateClient, type Client, type ClientInput, type ClientPatch, } from './repos-clients' @@ -58,11 +58,11 @@ import { costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, } from './repos-aws' 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 { makeRateLimiter } from './rate-limit' import { - addProjectMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, + addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, projectsPendingMilestone, setMilestone, } from './repos-milestones' 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['status'] === 'string') filter.status = req.query['status'] 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']) if (Number.isInteger(page) && page >= 1) filter.page = page 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) }) } }) + // 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) ---------- // D18: the composer autofills invoice due dates from this (issue also stamps with it). diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index bb22b08..3c3ee20 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -175,6 +175,26 @@ export async function revealClientDbPassword(db: DB, userId: string, id: string, 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 * updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 508aa25..6e3bd04 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -71,6 +71,8 @@ export async function getDocument(db: DB, id: string): Promise { export interface DocumentFilter { type?: string; status?: string; clientId?: string + /** Free-text search over document number and client name (D26). */ + q?: string /** 1-based page (floor 1). */ page?: number /** 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.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) } 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 }>( - `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 const rows = await db.all( `SELECT d.*, c.name AS client_name diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index f2ed6e7..5aba315 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -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) ---------- export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } diff --git a/apps/hq/src/repos-reports.ts b/apps/hq/src/repos-reports.ts index d387c50..721e42b 100644 --- a/apps/hq/src/repos-reports.ts +++ b/apps/hq/src/repos-reports.ts @@ -11,6 +11,55 @@ import { awsCostForClient } from './repos-aws' 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) diff --git a/apps/hq/test/functional-d26.test.ts b/apps/hq/test/functional-d26.test.ts new file mode 100644 index 0000000..a7256f0 --- /dev/null +++ b/apps/hq/test/functional-d26.test.ts @@ -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' }) + }) +})