|
|
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 { resolveUsageRate } from './repos-rate-card'
|
|
|
import { dismissQuoteFollowups, getNumberSetting } 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; dueDate: string | null; 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; due_date: string | null; 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, dueDate: r.due_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 async function getDocument(db: DB, id: string): Promise<Doc | null> {
|
|
|
const row = await db.get<DocRow>(`SELECT * FROM document WHERE id=?`, id)
|
|
|
return row === undefined ? null : toDoc(row)
|
|
|
}
|
|
|
|
|
|
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. */
|
|
|
pageSize?: number
|
|
|
}
|
|
|
|
|
|
/** One list row: the document plus the joined client name (list views need it without N+1 fetches). */
|
|
|
export type DocListRow = Doc & { clientName: string }
|
|
|
|
|
|
export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
|
|
|
|
|
|
/** Filtered document list, newest first, paginated with a true total (rule 8 — no silent caps). */
|
|
|
export async function listDocuments(db: DB, filter: DocumentFilter = {}): Promise<DocumentsPage> {
|
|
|
const page = Math.max(1, filter.page ?? 1)
|
|
|
const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50))
|
|
|
let where = ` WHERE 1=1`
|
|
|
const args: unknown[] = []
|
|
|
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 JOIN client c ON c.id = d.client_id${where}`, ...args,
|
|
|
))!.n
|
|
|
const rows = await db.all<DocRow & { client_name: string }>(
|
|
|
`SELECT d.*, c.name AS client_name
|
|
|
FROM document d JOIN client c ON c.id = d.client_id${where}
|
|
|
ORDER BY d.id DESC LIMIT ? OFFSET ?`, // uuidv7 ids sort by creation time
|
|
|
...args, pageSize, (page - 1) * pageSize)
|
|
|
return {
|
|
|
documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })),
|
|
|
total, page, pageSize,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ---------- document events ----------
|
|
|
|
|
|
export interface DocumentEvent {
|
|
|
id: string; documentId: string; atWall: string; kind: string; meta: Record<string, unknown>
|
|
|
}
|
|
|
|
|
|
interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string }
|
|
|
|
|
|
async function addEvent(db: DB, documentId: string, kind: string, meta: Record<string, unknown> = {}): Promise<void> {
|
|
|
await db.run(
|
|
|
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
|
|
|
uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta))
|
|
|
}
|
|
|
|
|
|
export async function listDocumentEvents(db: DB, documentId: string): Promise<DocumentEvent[]> {
|
|
|
const rows = await db.all<EventRow>(
|
|
|
`SELECT * FROM document_event WHERE document_id=? ORDER BY id`, documentId)
|
|
|
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<string, unknown>,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
// ---------- 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
|
|
|
/** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */
|
|
|
dueDate?: string
|
|
|
}
|
|
|
|
|
|
const todayIso = (): string => new Date().toISOString().slice(0, 10)
|
|
|
|
|
|
/** YYYY-MM-DD + N days (UTC). Local copy — importing scheduler's helper would cycle. */
|
|
|
const addDays = (dateIso: string, days: number): string => {
|
|
|
const [y, m, d] = dateIso.split('-').map(Number)
|
|
|
return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10)
|
|
|
}
|
|
|
|
|
|
async function supplyStateCode(db: DB): Promise<string> {
|
|
|
const row = await db.get<{ value: string }>(
|
|
|
`SELECT value FROM setting WHERE key='company.state_code'`)
|
|
|
// 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
|
|
|
}
|
|
|
|
|
|
async function taxRates(db: DB): Promise<TaxClassRow[]> {
|
|
|
const rows = await db.all<{
|
|
|
class_code: string; rate_pct_bp: number; cess_pct_bp: number
|
|
|
effective_from: string; effective_to: string | null
|
|
|
}>(`SELECT * FROM tax_class`)
|
|
|
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 } : {}),
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): Promise<LineInput[]> {
|
|
|
const out: LineInput[] = []
|
|
|
for (const line of inputs) {
|
|
|
const mod = await getModule(db, line.moduleId)
|
|
|
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
|
|
|
// D24 (red-team): HQ line quantities are always whole units (SMS counts, module seats,
|
|
|
// AMC years) — reject fractional / non-integer / overflowing qty before it reaches paise.
|
|
|
if (!Number.isInteger(line.qty) || line.qty < 1 || line.qty > 100_000_000) {
|
|
|
throw new Error(`Quantity for ${mod.code} must be a whole number between 1 and 100,000,000`)
|
|
|
}
|
|
|
const edition = line.edition ?? 'standard'
|
|
|
// Price resolution order: (1) an explicit per-line override always wins (special deal);
|
|
|
// (2) a quantity-tiered rate card if the module has one (D23 — SMS packs price by count);
|
|
|
// (3) the dated price book. A tiered module below its minimum order blocks the save.
|
|
|
let unitPricePaise = line.unitPricePaise ?? null
|
|
|
if (unitPricePaise === null) {
|
|
|
const tiered = await resolveUsageRate(db, line.moduleId, line.qty, onDate)
|
|
|
if (tiered === null) {
|
|
|
unitPricePaise = await priceOn(db, line.moduleId, line.kind, edition, onDate)
|
|
|
} else if (tiered.kind === 'below_min') {
|
|
|
if (warnings === undefined) {
|
|
|
throw new Error(`${mod.code}: minimum order is ${tiered.minQty.toLocaleString('en-IN')} units — enter at least that many`)
|
|
|
}
|
|
|
warnings.push(`${mod.code}: below the ${tiered.minQty.toLocaleString('en-IN')} minimum order`)
|
|
|
unitPricePaise = 0
|
|
|
} else {
|
|
|
unitPricePaise = tiered.ratePaise
|
|
|
}
|
|
|
}
|
|
|
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
|
|
|
out.push({
|
|
|
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,
|
|
|
})
|
|
|
}
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
|
|
|
async function insertDocRow(db: DB, userId: string, a: {
|
|
|
docType: DocType; clientId: string; refDocId: string | null; docDate: string
|
|
|
totals: BillTotals; payload: DocPayload; dueDate?: string | null
|
|
|
}): Promise<Doc> {
|
|
|
const id = uuidv7()
|
|
|
const t = a.totals
|
|
|
await db.run(
|
|
|
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
|
|
|
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
|
|
|
JSON.stringify(a.payload), userId, new Date().toISOString())
|
|
|
await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
|
|
|
const doc = (await getDocument(db, id))!
|
|
|
await 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[]
|
|
|
dueDate?: 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 async function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): Promise<PreparedDraft> {
|
|
|
const permissive = opts.permissive === true
|
|
|
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
|
|
|
throw new Error(`Cannot draft doc type: ${input.docType}`)
|
|
|
}
|
|
|
// Due dates are payment terms — they belong to invoices only (D18, spec A2).
|
|
|
if (input.dueDate !== undefined) {
|
|
|
if (input.docType !== 'INVOICE') throw new Error('Due date applies to INVOICE drafts only')
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.dueDate)) throw new Error('dueDate must be YYYY-MM-DD')
|
|
|
}
|
|
|
const client = await 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 = await supplyStateCode(db)
|
|
|
const computed = computeBill(await buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
|
|
|
businessDate: today,
|
|
|
supplyStateCode: supply,
|
|
|
placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
|
|
|
roundToRupee: true,
|
|
|
}, await taxRates(db))
|
|
|
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
|
|
|
const lineContents: string[][] = []
|
|
|
for (const line of lineInputs) {
|
|
|
lineContents.push(line.contentLines ?? (await 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,
|
|
|
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function createDraft(db: DB, userId: string, input: DraftInput): Promise<Doc> {
|
|
|
const prepared = await 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,
|
|
|
dueDate: prepared.dueDate ?? null,
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
// ---------- lifecycle ----------
|
|
|
|
|
|
/** Issue = number assignment only; status stays draft until sent/marked. */
|
|
|
export async function issueDocument(db: DB, userId: string, docId: string): Promise<Doc> {
|
|
|
return db.transaction(async () => {
|
|
|
const before = await 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 = await nextDocNo(db, before.docType, before.fy)
|
|
|
await db.run(`UPDATE document SET doc_no=? WHERE id=?`, docNo, docId)
|
|
|
// D18: an invoice that reaches issue without a due date gets doc_date + terms
|
|
|
// stamped NOW — the last moment it is still a draft (issued paper never changes).
|
|
|
let dueDate = before.dueDate
|
|
|
if (before.docType === 'INVOICE' && dueDate === null) {
|
|
|
const terms = await getNumberSetting(db, 'billing.payment_terms_days', 15)
|
|
|
dueDate = addDays(before.docDate, terms)
|
|
|
await db.run(`UPDATE document SET due_date=? WHERE id=?`, dueDate, docId)
|
|
|
}
|
|
|
await addEvent(db, docId, 'issued', { docNo })
|
|
|
await writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
|
|
|
return (await getDocument(db, docId))!
|
|
|
})
|
|
|
}
|
|
|
|
|
|
const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
|
|
|
draft: ['sent'],
|
|
|
sent: ['accepted', 'lost'],
|
|
|
}
|
|
|
|
|
|
export async function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Promise<Doc> {
|
|
|
return db.transaction(async () => {
|
|
|
const before = await getDocument(db, docId)
|
|
|
if (before === null) throw new Error('Document not found')
|
|
|
// D24 (red-team): accepted/lost are QUOTATION lifecycle states. An issued INVOICE or
|
|
|
// PROFORMA must never be pushed there — that would hide an invoice from overdue chasing
|
|
|
// and bypass the cancel/credit-note discipline. Invoices change state only through
|
|
|
// settlement (repos-payments) or cancel/credit-note.
|
|
|
if ((status === 'accepted' || status === 'lost') && before.docType !== 'QUOTATION') {
|
|
|
throw new Error(`Only quotations can be marked ${status}; a ${before.docType} cannot`)
|
|
|
}
|
|
|
if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) {
|
|
|
throw new Error(`Illegal status transition: ${before.status} → ${status}`)
|
|
|
}
|
|
|
await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
|
|
|
await addEvent(db, docId, status)
|
|
|
await 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.
|
|
|
await dismissQuoteFollowups(db, userId, docId)
|
|
|
}
|
|
|
return (await getDocument(db, docId))!
|
|
|
})
|
|
|
}
|
|
|
|
|
|
const CHAIN: Record<string, number> = { 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 async function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Promise<Doc> {
|
|
|
return db.transaction(async () => {
|
|
|
const src = await 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 = await db.get<{ doc_type: string }>(
|
|
|
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
|
|
|
src.id)
|
|
|
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 = await 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: await supplyStateCode(db),
|
|
|
placeOfSupplyStateCode: client.stateCode,
|
|
|
roundToRupee: true,
|
|
|
}, await taxRates(db))
|
|
|
totals = computed.totals
|
|
|
payload = { ...src.payload, lines: computed.lines, totals: computed.totals }
|
|
|
}
|
|
|
const doc = await insertDocRow(db, userId, {
|
|
|
docType: to, clientId: src.clientId, refDocId: src.id,
|
|
|
docDate, totals, payload,
|
|
|
})
|
|
|
await 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).
|
|
|
await db.run(`UPDATE document SET status='invoiced' WHERE id=?`, src.id)
|
|
|
await 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.
|
|
|
await dismissQuoteFollowups(db, userId, src.id)
|
|
|
}
|
|
|
return doc
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/** Only issued, unpaid documents; the consumed number is never reused. */
|
|
|
export async function cancelDocument(db: DB, userId: string, docId: string, reason = ''): Promise<Doc> {
|
|
|
return db.transaction(async () => {
|
|
|
const before = await 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 why = reason.trim()
|
|
|
if (why === '') throw new Error('A reason is required to cancel a document')
|
|
|
const alloc = (await db.get<{ n: number }>(
|
|
|
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))!
|
|
|
if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it')
|
|
|
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
|
|
|
await addEvent(db, docId, 'cancelled', { reason: why })
|
|
|
await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled', reason: why })
|
|
|
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.
|
|
|
await dismissQuoteFollowups(db, userId, docId)
|
|
|
}
|
|
|
return (await getDocument(db, docId))!
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Supersede a proforma (spec §8): cancel it — the consumed number is never reused
|
|
|
* when issued — and open a fresh PROFORMA draft carrying the payload verbatim,
|
|
|
* linked via ref_doc_id. The "trivially easy cancel & recreate". Proforma-only:
|
|
|
* an INVOICE is immutable legal paper (cancel if unpaid, else credit note), and
|
|
|
* a QUOTATION re-drafts through the normal composer.
|
|
|
*/
|
|
|
export async function supersedeProforma(db: DB, userId: string, docId: string): Promise<Doc> {
|
|
|
return db.transaction(async () => {
|
|
|
const src = await getDocument(db, docId)
|
|
|
if (src === null) throw new Error('Document not found')
|
|
|
if (src.docType === 'INVOICE') {
|
|
|
throw new Error('Invoices are immutable — cancel if unpaid, or raise a credit note')
|
|
|
}
|
|
|
if (src.docType !== 'PROFORMA') throw new Error('Only proformas can be superseded')
|
|
|
if (src.status === 'cancelled') throw new Error('Document is already cancelled')
|
|
|
// A live forward child means the sale already moved on — superseding would fork it.
|
|
|
const child = await db.get<{ doc_type: string }>(
|
|
|
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
|
|
|
src.id)
|
|
|
if (child !== undefined) {
|
|
|
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
|
|
|
}
|
|
|
if (src.docNo !== null) {
|
|
|
await cancelDocument(db, userId, docId, 'Superseded & recreated') // issued: number stays consumed (rule 4)
|
|
|
} else {
|
|
|
// Unissued draft: no number to preserve; retire it the same audited way.
|
|
|
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
|
|
|
await addEvent(db, docId, 'cancelled')
|
|
|
await writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' })
|
|
|
}
|
|
|
const doc = await insertDocRow(db, userId, {
|
|
|
docType: 'PROFORMA', clientId: src.clientId, refDocId: src.id,
|
|
|
docDate: todayIso(), totals: src.payload.totals, payload: src.payload,
|
|
|
})
|
|
|
await addEvent(db, src.id, 'superseded', { newDocId: doc.id })
|
|
|
return doc
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 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 async function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Promise<Doc> {
|
|
|
const inv = await 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 = await 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
|
|
|
? await 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: await supplyStateCode(db),
|
|
|
placeOfSupplyStateCode: client.stateCode,
|
|
|
roundToRupee: true,
|
|
|
}, await taxRates(db))
|
|
|
return db.transaction(async () => {
|
|
|
// D24 (red-team): a credit note can never reverse more than the invoice's remaining
|
|
|
// value. Sum the value already credited by non-cancelled CNs on this invoice and cap
|
|
|
// the new CN to what's left. Read + insert in the same transaction so concurrent CNs
|
|
|
// cannot both slip past the check (CGST Act s.34 — a CN corrects, it does not exceed).
|
|
|
const already = ((await db.get<{ n: number }>(
|
|
|
`SELECT COALESCE(SUM(payable_paise), 0) AS n FROM document
|
|
|
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status <> 'cancelled'`,
|
|
|
inv.id,
|
|
|
))!).n
|
|
|
const remaining = inv.payablePaise - already
|
|
|
if (computed.totals.payablePaise > remaining) {
|
|
|
throw new Error(
|
|
|
`Credit note (${computed.totals.payablePaise} paise) exceeds the invoice's remaining creditable value `
|
|
|
+ `(${remaining} paise: ${inv.payablePaise} invoiced − ${already} already credited)`,
|
|
|
)
|
|
|
}
|
|
|
const doc = await insertDocRow(db, userId, {
|
|
|
docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id,
|
|
|
docDate: todayIso(), totals: computed.totals,
|
|
|
payload: { lines: computed.lines, totals: computed.totals },
|
|
|
})
|
|
|
await 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 async function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Promise<Doc> {
|
|
|
const client = await 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(async () => {
|
|
|
const draft = await 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
|
|
|
})
|
|
|
}
|