import type { BillLine, BillTotals, Paise, IsoDate } from '@sims/domain' import { resolveTaxClass, type TaxClassRow } from './tax' export interface LineInput { itemId: string name: string hsn: string qty: number unitCode: string unitPricePaise: Paise priceIncludesTax: boolean taxClassCode: string mrpPaise?: Paise discount?: { kind: 'percentBp'; value: number } | { kind: 'amount'; paise: Paise } batchId?: string } export interface BillContext { businessDate: IsoDate /** Store's GST state code. */ supplyStateCode: string /** Customer/delivery state; equal to supply state ⇒ CGST+SGST, else IGST. */ placeOfSupplyStateCode: string roundToRupee: boolean billDiscountPaise?: Paise } export interface ComputedBill { lines: BillLine[] totals: BillTotals } /** * Deterministic money rules — the same engine must produce byte-identical * results on POS, store server, and cloud: * - integer paise throughout; each rounding step is explicit * - inclusive price: line total stays the sticker amount; taxable is derived * - intra-state split: cgst = floor(tax/2), sgst = tax − cgst (sum exact) * - bill discount allocates pro-rata by line gross, largest-remainder method */ export function computeBill(inputs: LineInput[], ctx: BillContext, rates: TaxClassRow[]): ComputedBill { if (inputs.length === 0) throw new Error('Cannot compute an empty bill') for (const line of inputs) { // Returns are separate documents (immutability rule) — never negative lines. // Quantity may be fractional (weighed goods, e.g. 1.25 kg) but must be finite and // magnitude-bounded: an astronomically large qty would flow into stored paise totals // and, past 2^53, silently lose precision (paise are integers, JS numbers are not // exact beyond that). Integer-only enforcement lives at the HQ document layer, where // units are always whole (SMS counts, module seats). if (!(line.qty > 0) || !Number.isFinite(line.qty) || line.qty > 100_000_000) { throw new Error(`Quantity must be a positive number up to 100,000,000 for "${line.name}"`) } if (!Number.isInteger(line.unitPricePaise) || line.unitPricePaise < 0 || line.unitPricePaise > 1_000_000_000_00) { throw new Error(`Unit price must be a non-negative integer paise (≤ ₹1,000 crore) for "${line.name}"`) } if (line.discount !== undefined) { const v = line.discount.kind === 'percentBp' ? line.discount.value : line.discount.paise if (v < 0) throw new Error(`Discount must be non-negative for "${line.name}"`) if (line.discount.kind === 'percentBp' && line.discount.value > 10_000) { throw new Error(`Discount above 100% for "${line.name}"`) } } } if ((ctx.billDiscountPaise ?? 0) < 0) throw new Error('Bill discount must be non-negative') const interState = ctx.supplyStateCode !== ctx.placeOfSupplyStateCode const grossAfterLineDisc = inputs.map((line) => { const gross = Math.round(line.unitPricePaise * line.qty) const disc = line.discount === undefined ? 0 : line.discount.kind === 'percentBp' ? Math.round((gross * line.discount.value) / 10_000) : line.discount.paise if (disc > gross) throw new Error(`Discount exceeds line amount for "${line.name}"`) return { gross, disc, net: gross - disc } }) const billDiscShares = allocateProRata( ctx.billDiscountPaise ?? 0, grossAfterLineDisc.map((g) => g.net), ) const lines: BillLine[] = inputs.map((line, i) => { const { gross, disc } = grossAfterLineDisc[i]! const discount = disc + billDiscShares[i]! const net = gross - discount const rate = resolveTaxClass(rates, line.taxClassCode, ctx.businessDate) const totalRateBp = rate.ratePctBp + rate.cessPctBp let taxable: Paise let taxPlusCess: Paise let lineTotal: Paise if (line.priceIncludesTax) { taxable = Math.round((net * 10_000) / (10_000 + totalRateBp)) taxPlusCess = net - taxable lineTotal = net } else { taxable = net taxPlusCess = Math.round((taxable * totalRateBp) / 10_000) lineTotal = taxable + taxPlusCess } const cess = totalRateBp === 0 ? 0 : Math.round((taxPlusCess * rate.cessPctBp) / totalRateBp) const tax = taxPlusCess - cess const cgst = interState ? 0 : Math.floor(tax / 2) const sgst = interState ? 0 : tax - cgst const igst = interState ? tax : 0 return { itemId: line.itemId, name: line.name, hsn: line.hsn, qty: line.qty, unitCode: line.unitCode, unitPricePaise: line.unitPricePaise, ...(line.mrpPaise !== undefined ? { mrpPaise: line.mrpPaise } : {}), grossPaise: gross, discountPaise: discount, taxablePaise: taxable, taxRateBp: rate.ratePctBp, cgstPaise: cgst, sgstPaise: sgst, igstPaise: igst, cessPaise: cess, lineTotalPaise: lineTotal, ...(line.batchId !== undefined ? { batchId: line.batchId } : {}), } }) const sum = (f: (l: BillLine) => number) => lines.reduce((a, l) => a + f(l), 0) const total = sum((l) => l.lineTotalPaise) const payable = ctx.roundToRupee ? Math.round(total / 100) * 100 : total const savings = lines.reduce((acc, l) => { if (l.mrpPaise === undefined) return acc return acc + Math.max(0, Math.round(l.mrpPaise * l.qty) - l.lineTotalPaise) }, 0) return { lines, totals: { grossPaise: sum((l) => l.grossPaise), discountPaise: sum((l) => l.discountPaise), taxablePaise: sum((l) => l.taxablePaise), cgstPaise: sum((l) => l.cgstPaise), sgstPaise: sum((l) => l.sgstPaise), igstPaise: sum((l) => l.igstPaise), cessPaise: sum((l) => l.cessPaise), roundOffPaise: payable - total, payablePaise: payable, savingsVsMrpPaise: savings, }, } } /** Largest-remainder allocation; shares always sum exactly to `amount`. */ export function allocateProRata(amount: Paise, weights: number[]): Paise[] { const totalWeight = weights.reduce((a, w) => a + w, 0) if (amount === 0) return weights.map(() => 0) if (totalWeight === 0) { // Never silently drop money: allocating a nonzero amount needs weight somewhere. throw new Error('Cannot allocate a nonzero amount over zero total weight') } const raw = weights.map((w) => (amount * w) / totalWeight) const shares = raw.map(Math.floor) let remainder = amount - shares.reduce((a, s) => a + s, 0) const order = raw .map((r, i) => ({ frac: r - Math.floor(r), i })) .sort((a, b) => b.frac - a.frac || a.i - b.i) for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) { shares[order[k]!.i]! += 1 } return shares }