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.
105 lines
4.4 KiB
TypeScript
105 lines
4.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
formatINR, fyOf, fyShort, uuidv7, timestampOfUuidv7,
|
|
formatDocNo, nextDocNo, rolloverForFy, seriesPrefix, assertGstDocNo,
|
|
gstinCheckDigit, validateGstin, deriveSupply,
|
|
type DocSeries,
|
|
} from '@sims/domain'
|
|
|
|
describe('money — Indian grouping', () => {
|
|
it('groups lakh/crore style', () => {
|
|
expect(formatINR(123_456_789)).toBe('₹12,34,567.89')
|
|
expect(formatINR(100)).toBe('₹1.00')
|
|
expect(formatINR(-4_50)).toBe('-₹4.50')
|
|
expect(formatINR(99_999, { symbol: false })).toBe('999.99')
|
|
})
|
|
})
|
|
|
|
describe('financial year (Day Begin idiom)', () => {
|
|
it('splits at 1 April', () => {
|
|
expect(fyOf('2026-03-31')).toBe('2025-26')
|
|
expect(fyOf('2026-04-01')).toBe('2026-27')
|
|
expect(fyShort('2026-27')).toBe('26')
|
|
})
|
|
})
|
|
|
|
describe('uuidv7', () => {
|
|
it('is well-formed and carries its timestamp', () => {
|
|
const at = 1_750_000_000_000
|
|
const id = uuidv7(at)
|
|
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
expect(timestampOfUuidv7(id)).toBe(at)
|
|
})
|
|
it('sorts by creation time', () => {
|
|
expect(uuidv7(1000) < uuidv7(2000)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('document series — offline per-counter numbering', () => {
|
|
const series: DocSeries = {
|
|
tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE',
|
|
fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '2026-27'), nextSeq: 123,
|
|
}
|
|
it('formats within the GST 16-char rule (4-digit FY, M9)', () => {
|
|
const { docNo, series: bumped } = nextDocNo(series)
|
|
expect(docNo).toBe('ST1C22026-000123')
|
|
expect(docNo.length).toBeLessThanOrEqual(16)
|
|
expect(bumped.nextSeq).toBe(124)
|
|
})
|
|
it('M9: the 4-digit FY start year makes centuries-apart FYs distinct (no prefix collision)', () => {
|
|
const a = seriesPrefix('ST1', 'C2', '2026-27')
|
|
const b = seriesPrefix('ST1', 'C2', '2126-27') // 100 years later — collided under the old 2-digit "26"
|
|
expect(a).toBe('ST1C22026')
|
|
expect(b).toBe('ST1C22126')
|
|
expect(a).not.toBe(b)
|
|
// both still fit the 16-char [A-Za-z0-9/-] rule at the top of the 6-digit sequence range
|
|
expect(formatDocNo(a, 999_999)).toBe('ST1C22026-999999')
|
|
expect(formatDocNo(a, 999_999).length).toBe(16)
|
|
expect(() => formatDocNo(b, 999_999)).not.toThrow()
|
|
})
|
|
it('rejects numbers that break the GST rule', () => {
|
|
expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/)
|
|
expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed
|
|
})
|
|
it('FY rollover restarts the sequence', () => {
|
|
const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '2027-28'))
|
|
expect(rolled.nextSeq).toBe(1)
|
|
expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series)
|
|
})
|
|
})
|
|
|
|
describe('GSTIN validation', () => {
|
|
const body = '32ABCDE1234F1Z'
|
|
const valid = body + gstinCheckDigit(body)
|
|
it('accepts a checksum-correct GSTIN and extracts the state', () => {
|
|
expect(validateGstin(valid)).toEqual({ ok: true, stateCode: '32' })
|
|
})
|
|
it('rejects wrong checksum and wrong shape', () => {
|
|
const flipped = body + (valid[14] === 'A' ? 'B' : 'A')
|
|
expect(validateGstin(flipped).ok).toBe(false)
|
|
expect(validateGstin('not-a-gstin').ok).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('deriveSupply — place of supply at the counter', () => {
|
|
const store = '32' // MegaMart's state
|
|
const intraGstin = '32ABCDE1234F1Z' + gstinCheckDigit('32ABCDE1234F1Z')
|
|
const interGstin = '27ABCDE1234F1Z' + gstinCheckDigit('27ABCDE1234F1Z')
|
|
|
|
it('no GSTIN → B2C, place of supply is the store state (intra)', () => {
|
|
expect(deriveSupply(undefined, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
|
|
// A walk-in with only a state on file is still B2C — place of supply stays the store.
|
|
expect(deriveSupply({ stateCode: '27' }, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
|
|
})
|
|
it('GSTIN in the same state → B2B intra (CGST/SGST)', () => {
|
|
expect(deriveSupply({ gstin: intraGstin }, store)).toMatchObject({
|
|
b2b: true, placeOfSupplyStateCode: '32', interState: false, buyerGstin: intraGstin,
|
|
})
|
|
})
|
|
it('GSTIN in another state → B2B inter (IGST)', () => {
|
|
expect(deriveSupply({ gstin: interGstin }, store)).toMatchObject({
|
|
b2b: true, placeOfSupplyStateCode: '27', interState: true, buyerGstin: interGstin,
|
|
})
|
|
})
|
|
})
|