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 { dismissQuoteFollowups } from './repos-reminders' 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 ReceiptMeta { paymentId: string; receivedOn: string; mode: string; reference: string amountPaise: number; tdsPaise: number allocations: { docNo: string; amountPaise: number }[] } export interface DocPayload { lines: BillLine[]; totals: BillTotals; terms?: string /** Per-line "what's included" bullets, parallel to `lines` (empty array = nothing to print). */ lineContents?: string[][] /** Present only on RECEIPT documents — payment acknowledgment metadata. */ receipt?: ReceiptMeta } 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 /** "What's included" bullets for this line; default = the module's quoteContent. */ contentLines?: 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, warnings?: 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' let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate) if (unitPricePaise === null) { // Strict (no warnings sink): refuse — same message save has always thrown. if (warnings === undefined) { throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`) } // Permissive preview: keep the staffer typing — ₹0 now, reported so the cell can flag. warnings.push(`No price for ${mod.code} (${line.kind}/${edition}) — enter Unit ₹`) unitPricePaise = 0 } const packSuffix = edition !== 'standard' ? ` — ${edition}` : '' // pack name prints on the line return { itemId: mod.id, name: mod.name + (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : '') + packSuffix, 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 } /** Zero totals for the empty-composer preview (computeBill throws on an empty bill). */ function zeroTotals(): BillTotals { return { grossPaise: 0, discountPaise: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 0, savingsVsMrpPaise: 0, } } export interface PreparedDraft { docType: DocType; clientId: string; docDate: string totals: BillTotals; payload: DocPayload; warnings: string[] } /** * The compute half of a draft — pure, persists nothing. `createDraft` calls this * then inserts; `POST /documents/preview` calls it with { permissive: true }. So * save and preview run the ONE tax path and cannot drift. Permissive forgives a * price gap (₹0 + warning) and a not-yet-picked client (intra-state default); * strict (the default, used by save) throws exactly as before. */ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft { const permissive = opts.permissive === true 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 && !permissive) throw new Error('Client not found') const today = todayIso() const lineInputs = Array.isArray(input.lines) ? input.lines : [] const warnings: string[] = [] if (client === null && permissive) warnings.push('No client selected — GST split shown as pending') // Empty composer under preview: real letterhead, empty table, ₹0 (design §3). if (permissive && lineInputs.length === 0) { const zero = zeroTotals() return { docType: input.docType, clientId: input.clientId, docDate: today, totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings, } } const supply = supplyStateCode(db) const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), { businessDate: today, supplyStateCode: supply, placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default roundToRupee: true, }, taxRates(db)) // Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists). const lineContents = lineInputs.map((line) => line.contentLines ?? getModule(db, line.moduleId)!.quoteContent) const payload: DocPayload = { lines: computed.lines, totals: computed.totals, lineContents, ...(input.terms !== undefined ? { terms: input.terms } : {}), } return { docType: input.docType, clientId: input.clientId, docDate: today, totals: computed.totals, payload, warnings, } } export function createDraft(db: DB, userId: string, input: DraftInput): Doc { const prepared = prepareDraft(db, input) // strict — save refuses a bad document return db.transaction(() => insertDocRow(db, userId, { docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.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 }) if (status === 'accepted' || status === 'lost') { // STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open // follow-up nudges in this same transaction, one audited row each. dismissQuoteFollowups(db, userId, docId) } return getDocument(db, docId)! })() } const CHAIN: Record = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 } /** * New draft carrying the source's lines forward, linked via ref_doc_id. * QT→PI copies totals verbatim (both non-legal); →INVOICE recomputes through * computeBill on the invoice's own doc_date (spec §8 F3, hard rule 2). */ 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') // Rule-4 guard (spec §8 F4): one sale, one forward document. A live (non-cancelled) // child means this document was already converted — a second convert would mint a // duplicate for the same sale. Cancelling the child re-opens the path. const child = db.prepare( `SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`, ).get(src.id) as { doc_type: string } | undefined if (child !== undefined) { throw new Error(`Already converted: a live ${child.doc_type} exists for this document`) } const docDate = todayIso() let totals = src.payload.totals let payload = src.payload if (to === 'INVOICE') { // Rule-2 fix (spec §8 F3): an invoice is legal paper — recompute the carried // lines on the invoice's date (mirrors createCreditNote), so a dated tax_class // change between quote/proforma and invoice lands at the rate that is law today. const client = getClient(db, src.clientId) if (client === null) throw new Error('Client not found') const computed = computeBill( src.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, })), { businessDate: docDate, supplyStateCode: supplyStateCode(db), placeOfSupplyStateCode: client.stateCode, roundToRupee: true, }, taxRates(db)) totals = computed.totals payload = { ...src.payload, lines: computed.lines, totals: computed.totals } } const doc = insertDocRow(db, userId, { docType: to, clientId: src.clientId, refDocId: src.id, docDate, totals, payload, }) addEvent(db, src.id, 'converted', { to, newDocId: doc.id }) // ANY conversion moves the source forward — out of the 'sent' set the follow-up // scan and the pipeline read, so a converted quote is never chased again (spec §7). db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id) writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' }) if (src.docType === 'QUOTATION') { // STOP cleanup (spec §7): a converted quote has moved forward — dismiss its // open follow-up nudges in this same transaction, one audited row each. dismissQuoteFollowups(db, userId, src.id) } 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' }) if (before.docType === 'QUOTATION') { // STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges // must not chase the client. Same transaction, one audited row each. dismissQuoteFollowups(db, userId, docId) } 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 })() } export interface GenerateReceiptInput { clientId: string; paymentId: string; receivedOn: string; mode: string reference: string; amountPaise: number; tdsPaise: number allocations: { docNo: string; amountPaise: number }[] } /** * Payment receipt — a RECEIPT document with its own RCT/ series. Receipts * acknowledge money, they do not levy tax: zero GST, no line items, payable = * amount received. Issued (numbered) immediately and never edited thereafter. */ export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc { const client = getClient(db, input.clientId) if (client === null) throw new Error('Client not found') const totals: BillTotals = { grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: input.amountPaise, savingsVsMrpPaise: 0, } const payload: DocPayload = { lines: [], totals, receipt: { paymentId: input.paymentId, receivedOn: input.receivedOn, mode: input.mode, reference: input.reference, amountPaise: input.amountPaise, tdsPaise: input.tdsPaise, allocations: input.allocations, }, } return db.transaction(() => { const draft = insertDocRow(db, userId, { docType: 'RECEIPT', clientId: input.clientId, refDocId: null, docDate: todayIso(), totals, payload, }) return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N })() }