You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-documents.ts

616 lines
29 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
})
}