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/packages/billing-engine/src/compute.ts

168 lines
6.0 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 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.
if (!(line.qty > 0)) throw new Error(`Quantity must be positive for "${line.name}"`)
if (!Number.isInteger(line.unitPricePaise) || line.unitPricePaise < 0) {
throw new Error(`Unit price must be non-negative integer paise 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
}