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