diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 0df04f4..c444b8d 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,4 +1,4 @@ -import { Router, type Response } from 'express' +import { Router, type Request, type RequestHandler, type Response } from 'express' import { login, requireAuth, requireOwner } from './auth' import type { DB } from './db' import { @@ -10,6 +10,11 @@ import { listModules, listPrices, setPrice, updateClientModule, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type PriceInput, } from './repos-modules' +import { + cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, + issueDocument, listDocumentEvents, listDocuments, markStatus, + type DocumentFilter, type DraftInput, type DraftLineInput, +} from './repos-documents' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id @@ -123,5 +128,67 @@ export function apiRouter(db: DB): Router { } }) + // ---------- documents ---------- + r.post('/documents', requireAuth, (req, res) => { + try { + const document = createDraft(db, staffId(res), req.body as DraftInput) + res.json({ ok: true, document }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/documents', requireAuth, (req, res) => { + const filter: DocumentFilter = {} + 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'] + res.json({ ok: true, documents: listDocuments(db, filter) }) + }) + r.get('/documents/:id', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + const document = getDocument(db, id) + if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + const emails = db.prepare( + `SELECT * FROM email_log WHERE document_id=? ORDER BY id`, + ).all(id) + res.json({ ok: true, document, events: listDocumentEvents(db, id), emails }) + }) + + const docAction = (fn: (docId: string, res: Response, req: Request) => unknown): RequestHandler => + (req, res) => { + const id = String(req.params['id'] ?? '') + if (getDocument(db, id) === null) { + res.status(404).json({ ok: false, error: 'Document not found' }); return + } + try { + res.json({ ok: true, document: fn(id, res, req) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + } + + r.post('/documents/:id/issue', requireAuth, docAction((id, res) => + issueDocument(db, staffId(res), id))) + r.post('/documents/:id/status', requireAuth, docAction((id, res, req) => { + const { status } = req.body as { status?: string } + if (status !== 'sent' && status !== 'accepted' && status !== 'lost') { + throw new Error(`Status must be one of: sent, accepted, lost`) + } + return markStatus(db, staffId(res), id, status) + })) + r.post('/documents/:id/convert', requireAuth, docAction((id, res, req) => { + const { to } = req.body as { to?: string } + if (to !== 'PROFORMA' && to !== 'INVOICE') { + throw new Error(`Convert target must be PROFORMA or INVOICE`) + } + return convertDocument(db, staffId(res), id, to) + })) + r.post('/documents/:id/cancel', requireAuth, docAction((id, res) => + cancelDocument(db, staffId(res), id))) + r.post('/documents/:id/credit-note', requireAuth, docAction((id, res, req) => { + const { lines } = req.body as { lines?: DraftLineInput[] } + return createCreditNote(db, staffId(res), id, lines) + })) + return r } diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts new file mode 100644 index 0000000..5267aa2 --- /dev/null +++ b/apps/hq/src/repos-documents.ts @@ -0,0 +1,316 @@ +import { fyOf, uuidv7, type BillLine, type BillTotals } from '@sims/domain' +import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' +import { getModule, priceOn, type Kind } from './repos-modules' +import { nextDocNo } from './series' + +/** + * HQ document lifecycle (QT/PI/INV/CN) — plain functions over the handle + * (D12 portable-repo pattern). Issued documents are never edited or deleted: + * corrections are credit notes; cancel keeps the number consumed. + */ + +export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE' +export type DocStatus = + | 'draft' | 'sent' | 'accepted' | 'invoiced' | 'part_paid' | 'paid' | 'lost' | 'cancelled' + +/** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */ +const TAX_CLASS = 'GST18' + +export interface DocPayload { lines: BillLine[]; totals: BillTotals; terms?: string } + +export interface Doc { + id: string; docType: DocType; docNo: string | null; fy: string; clientId: string + docDate: string; status: DocStatus; refDocId: string | null + taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number + roundOffPaise: number; payablePaise: number + payload: DocPayload; source: string; createdBy: string; createdAt: string +} + +interface DocRow { + id: string; doc_type: string; doc_no: string | null; fy: string; client_id: string + doc_date: string; status: string; ref_doc_id: string | null + taxable_paise: number; cgst_paise: number; sgst_paise: number; igst_paise: number + round_off_paise: number; payable_paise: number + payload: string; source: string; created_by: string; created_at: string +} + +function toDoc(r: DocRow): Doc { + return { + id: r.id, docType: r.doc_type as DocType, docNo: r.doc_no, fy: r.fy, + clientId: r.client_id, docDate: r.doc_date, status: r.status as DocStatus, + refDocId: r.ref_doc_id, + taxablePaise: r.taxable_paise, cgstPaise: r.cgst_paise, sgstPaise: r.sgst_paise, + igstPaise: r.igst_paise, roundOffPaise: r.round_off_paise, payablePaise: r.payable_paise, + payload: JSON.parse(r.payload) as DocPayload, + source: r.source, createdBy: r.created_by, createdAt: r.created_at, + } +} + +export function getDocument(db: DB, id: string): Doc | null { + const row = db.prepare(`SELECT * FROM document WHERE id=?`).get(id) as DocRow | undefined + return row === undefined ? null : toDoc(row) +} + +export interface DocumentFilter { type?: string; status?: string; clientId?: string } + +export function listDocuments(db: DB, filter: DocumentFilter = {}): Doc[] { + let sql = `SELECT * FROM document WHERE 1=1` + const args: unknown[] = [] + if (filter.type !== undefined) { sql += ` AND doc_type=?`; args.push(filter.type) } + if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) } + if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) } + sql += ` ORDER BY id DESC` // uuidv7 ids sort by creation time + return (db.prepare(sql).all(...args) as DocRow[]).map(toDoc) +} + +// ---------- document events ---------- + +export interface DocumentEvent { + id: string; documentId: string; atWall: string; kind: string; meta: Record +} + +interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string } + +function addEvent(db: DB, documentId: string, kind: string, meta: Record = {}): void { + db.prepare( + `INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`, + ).run(uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta)) +} + +export function listDocumentEvents(db: DB, documentId: string): DocumentEvent[] { + const rows = db.prepare( + `SELECT * FROM document_event WHERE document_id=? ORDER BY id`, + ).all(documentId) as EventRow[] + return rows.map((r) => ({ + id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind, + meta: JSON.parse(r.meta) as Record, + })) +} + +// ---------- draft creation ---------- + +export interface DraftLineInput { + moduleId: string; description?: string; qty: number + unitPricePaise?: number /* default: priceOn today */; kind: Kind; edition?: string +} + +export interface DraftInput { + docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE' + clientId: string + lines: DraftLineInput[] + terms?: string +} + +const todayIso = (): string => new Date().toISOString().slice(0, 10) + +function supplyStateCode(db: DB): string { + const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`) + .get() as { value: string } | undefined + // Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly. + if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`) + return row.value +} + +function taxRates(db: DB): TaxClassRow[] { + const rows = db.prepare(`SELECT * FROM tax_class`).all() as { + class_code: string; rate_pct_bp: number; cess_pct_bp: number + effective_from: string; effective_to: string | null + }[] + return rows.map((r) => ({ + classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp, + effectiveFrom: r.effective_from, + ...(r.effective_to !== null ? { effectiveTo: r.effective_to } : {}), + })) +} + +function buildLines(db: DB, inputs: DraftLineInput[], onDate: string): LineInput[] { + return inputs.map((line) => { + const mod = getModule(db, line.moduleId) + if (mod === null) throw new Error(`Module not found: ${line.moduleId}`) + const edition = line.edition ?? 'standard' + const unitPricePaise = + line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate) + if (unitPricePaise === null) { + throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`) + } + return { + itemId: mod.id, + name: mod.name + (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : ''), + hsn: mod.sac, // SAC code for our service lines; PDFs label the column "SAC" + qty: line.qty, + unitCode: 'NOS', + unitPricePaise, + priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive + taxClassCode: TAX_CLASS, + } + }) +} + +/** Insert a draft row + created event + audit. Callers wrap in a transaction. */ +function insertDocRow(db: DB, userId: string, a: { + docType: DocType; clientId: string; refDocId: string | null; docDate: string + totals: BillTotals; payload: DocPayload +}): Doc { + const id = uuidv7() + const t = a.totals + db.prepare( + `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id, + taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, + payload, created_by, created_at) + VALUES (?, ?, NULL, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.refDocId, + t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise, + JSON.stringify(a.payload), userId, new Date().toISOString(), + ) + addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {}) + const doc = getDocument(db, id)! + writeAudit(db, userId, 'create', 'document', id, undefined, { + docType: doc.docType, clientId: doc.clientId, + payablePaise: doc.payablePaise, refDocId: doc.refDocId, + }) + return doc +} + +export function createDraft(db: DB, userId: string, input: DraftInput): Doc { + if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) { + throw new Error(`Cannot draft doc type: ${input.docType}`) + } + const client = getClient(db, input.clientId) + if (client === null) throw new Error('Client not found') + const today = todayIso() + const computed = computeBill(buildLines(db, input.lines, today), { + businessDate: today, + supplyStateCode: supplyStateCode(db), + placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST + roundToRupee: true, + }, taxRates(db)) + const payload: DocPayload = { + lines: computed.lines, totals: computed.totals, + ...(input.terms !== undefined ? { terms: input.terms } : {}), + } + return db.transaction(() => insertDocRow(db, userId, { + docType: input.docType, clientId: input.clientId, refDocId: null, + docDate: today, totals: computed.totals, payload, + }))() +} + +// ---------- lifecycle ---------- + +/** Issue = number assignment only; status stays draft until sent/marked. */ +export function issueDocument(db: DB, userId: string, docId: string): Doc { + return db.transaction(() => { + const before = getDocument(db, docId) + if (before === null) throw new Error('Document not found') + if (before.docNo !== null) throw new Error(`Document already issued as ${before.docNo}`) + if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document') + const docNo = nextDocNo(db, before.docType, before.fy) + db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId) + addEvent(db, docId, 'issued', { docNo }) + writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo }) + return getDocument(db, docId)! + })() +} + +const LEGAL_MARKS: Partial> = { + draft: ['sent'], + sent: ['accepted', 'lost'], +} + +export function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Doc { + return db.transaction(() => { + const before = getDocument(db, docId) + if (before === null) throw new Error('Document not found') + if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) { + throw new Error(`Illegal status transition: ${before.status} → ${status}`) + } + db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId) + addEvent(db, docId, status) + writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status }) + return getDocument(db, docId)! + })() +} + +const CHAIN: Record = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 } + +/** New draft carrying the source's lines/totals verbatim, linked via ref_doc_id. */ +export function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Doc { + return db.transaction(() => { + const src = getDocument(db, docId) + if (src === null) throw new Error('Document not found') + const from = CHAIN[src.docType] + const target = CHAIN[to] + if (from === undefined || target === undefined || target <= from) { + throw new Error(`Cannot convert ${src.docType} to ${to}`) + } + if (src.status === 'cancelled') throw new Error('Cannot convert a cancelled document') + const doc = insertDocRow(db, userId, { + docType: to, clientId: src.clientId, refDocId: src.id, + docDate: todayIso(), totals: src.payload.totals, payload: src.payload, + }) + addEvent(db, src.id, 'converted', { to, newDocId: doc.id }) + if (to === 'INVOICE') { + db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id) + writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' }) + } + return doc + })() +} + +/** Only issued, unpaid documents; the consumed number is never reused. */ +export function cancelDocument(db: DB, userId: string, docId: string): Doc { + return db.transaction(() => { + const before = getDocument(db, docId) + if (before === null) throw new Error('Document not found') + if (before.docNo === null) throw new Error('Only issued documents can be cancelled') + if (before.status === 'cancelled') throw new Error('Document is already cancelled') + const alloc = db.prepare( + `SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, + ).get(docId) as { n: number } + if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it') + db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId) + addEvent(db, docId, 'cancelled') + writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' }) + return getDocument(db, docId)! + })() +} + +/** + * Credit note against an issued invoice; defaults to full value. Amounts are + * positive here — CN semantics are "negative" only in settlement math (Task 9). + */ +export function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Doc { + const inv = getDocument(db, invoiceId) + if (inv === null) throw new Error('Invoice not found') + if (inv.docType !== 'INVOICE') throw new Error('Credit notes can only be raised against invoices') + if (inv.docNo === null) throw new Error('Invoice must be issued before raising a credit note') + if (inv.status === 'cancelled') throw new Error('Cannot credit a cancelled invoice') + const client = getClient(db, inv.clientId) + if (client === null) throw new Error('Client not found') + // Recompute rather than copy: rates resolve on the invoice's date, so a CN + // against an old bill uses the rate that was law on that day. + const lineInputs = lines !== undefined + ? buildLines(db, lines, inv.docDate) + : inv.payload.lines.map((l): LineInput => ({ + itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode, + unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS, + })) + const computed = computeBill(lineInputs, { + businessDate: inv.docDate, + supplyStateCode: supplyStateCode(db), + placeOfSupplyStateCode: client.stateCode, + roundToRupee: true, + }, taxRates(db)) + return db.transaction(() => { + const doc = insertDocRow(db, userId, { + docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id, + docDate: todayIso(), totals: computed.totals, + payload: { lines: computed.lines, totals: computed.totals }, + }) + addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id }) + return doc + })() +} diff --git a/apps/hq/test/documents.test.ts b/apps/hq/test/documents.test.ts new file mode 100644 index 0000000..17a6f20 --- /dev/null +++ b/apps/hq/test/documents.test.ts @@ -0,0 +1,61 @@ +// apps/hq/test/documents.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument } from '../src/repos-documents' + +function setup() { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + return { db, c, m } +} + +describe('documents', () => { + it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', () => { + const { db, c, m } = setup() + const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + expect(d.taxablePaise).toBe(10_000_00) + expect(d.cgstPaise).toBe(900_00) + expect(d.sgstPaise).toBe(900_00) + expect(d.igstPaise).toBe(0) + expect(d.payablePaise).toBe(11_800_00) + expect(d.status).toBe('draft') + expect(d.docNo).toBeNull() + }) + it('inter-state client gets IGST', () => { + const { db, m } = setup() + const db2 = db + const kar = createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' }) + const d = createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + expect(d.igstPaise).toBe(1_800_00) + expect(d.cgstPaise).toBe(0) + }) + it('issue assigns a series number; convert QT→INV carries lines and links back', () => { + const { db, c, m } = setup() + const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + const issued = issueDocument(db, 'u1', qt.id) + expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/) + const inv = convertDocument(db, 'u1', qt.id, 'INVOICE') + expect(inv.refDocId).toBe(qt.id) + expect(inv.payablePaise).toBe(qt.payablePaise) + }) + it('credit note defaults to full value against the invoice; cancel keeps the number', () => { + const { db, c, m } = setup() + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + const cn = createCreditNote(db, 'u1', inv.id) + expect(cn.docType).toBe('CREDIT_NOTE') + expect(cn.payablePaise).toBe(inv.payablePaise) + const cancelled = cancelDocument(db, 'u1', inv.id) + expect(cancelled.status).toBe('cancelled') + expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused + }) +})