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/test/returns.test.ts

66 lines
3.2 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 { 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()
})
})