|
|
import { describe, expect, it } from 'vitest'
|
|
|
import { computeBill, computeReturn, type LineInput, type TaxClassRow } from '@sims/billing-engine'
|
|
|
|
|
|
/**
|
|
|
* The credit-note engine reverses a sale line PROPORTIONALLY and at the ORIGINAL captured rate.
|
|
|
* These lock: a full-line return reproduces the original amounts exactly; a partial return stays
|
|
|
* internally consistent (taxable + taxes == lineTotal) and reverses real GST; and the reversal
|
|
|
* scales the STORED rate/amounts, never a live rate table (so a later master change can't move it).
|
|
|
*/
|
|
|
const RATES: TaxClassRow[] = [
|
|
|
{ classCode: 'GST12', ratePctBp: 1200, cessPctBp: 0, effectiveFrom: '2017-07-01' },
|
|
|
]
|
|
|
const CTX = { businessDate: '2026-07-10', supplyStateCode: '32', placeOfSupplyStateCode: '32', roundToRupee: true }
|
|
|
|
|
|
// A sale of 3 × Amul Butter @ ₹275 tax-inclusive, GST12 (₹825.00 total).
|
|
|
function saleLines(): ReturnType<typeof computeBill>['lines'] {
|
|
|
const l: LineInput = {
|
|
|
itemId: '107', name: 'Amul Butter 500g', hsn: '0405', qty: 3, unitCode: 'PCS',
|
|
|
unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
|
|
|
}
|
|
|
return computeBill([l], CTX, RATES).lines
|
|
|
}
|
|
|
|
|
|
describe('computeReturn — proportional reversal at the original rate', () => {
|
|
|
it('a full-line return reproduces the original line amounts exactly', () => {
|
|
|
const orig = saleLines()
|
|
|
const o = orig[0]!
|
|
|
const r = computeReturn(orig, [{ lineIndex: 0, qty: 3 }], { roundToRupee: true })
|
|
|
const x = r.lines[0]!
|
|
|
expect(x.taxablePaise).toBe(o.taxablePaise)
|
|
|
expect(x.cgstPaise).toBe(o.cgstPaise)
|
|
|
expect(x.sgstPaise).toBe(o.sgstPaise)
|
|
|
expect(x.cessPaise).toBe(o.cessPaise)
|
|
|
expect(x.lineTotalPaise).toBe(o.lineTotalPaise)
|
|
|
expect(r.totals.payablePaise).toBe(Math.round(o.lineTotalPaise / 100) * 100)
|
|
|
expect(x.originalLineIndex).toBe(0)
|
|
|
})
|
|
|
|
|
|
it('a partial 2-of-3 return credits 2/3, stays consistent, and reverses real GST', () => {
|
|
|
const orig = saleLines()
|
|
|
const r = computeReturn(orig, [{ lineIndex: 0, qty: 2 }], { roundToRupee: true })
|
|
|
const x = r.lines[0]!
|
|
|
// 2/3 of the ₹825 inclusive line = ₹550.00 → 55000 paise.
|
|
|
expect(x.lineTotalPaise).toBe(55_000)
|
|
|
expect(r.totals.payablePaise).toBe(55_000)
|
|
|
// Internal consistency (the same invariant computeBill produces).
|
|
|
expect(x.taxablePaise + x.cgstPaise + x.sgstPaise + x.igstPaise + x.cessPaise).toBe(x.lineTotalPaise)
|
|
|
// GST is genuinely reversed, split as CGST+SGST (intra-state), not zeroed.
|
|
|
expect(x.cgstPaise + x.sgstPaise).toBeGreaterThan(0)
|
|
|
expect(x.igstPaise).toBe(0)
|
|
|
})
|
|
|
|
|
|
it('reverses at the ORIGINAL captured rate — it scales stored amounts, never a rate table', () => {
|
|
|
const orig = saleLines()
|
|
|
const r = computeReturn(orig, [{ lineIndex: 0, qty: 1 }], { roundToRupee: false })
|
|
|
// The captured rate rides on the credit-note line; no rate list is even passed in.
|
|
|
expect(r.lines[0]!.taxRateBp).toBe(1200)
|
|
|
})
|
|
|
|
|
|
it('rejects a return qty above what was sold (defence in depth; server enforces the ledger)', () => {
|
|
|
const orig = saleLines()
|
|
|
expect(() => computeReturn(orig, [{ lineIndex: 0, qty: 4 }], { roundToRupee: true })).toThrow()
|
|
|
expect(() => computeReturn(orig, [{ lineIndex: 9, qty: 1 }], { roundToRupee: true })).toThrow()
|
|
|
})
|
|
|
})
|