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.
87 lines
4.5 KiB
TypeScript
87 lines
4.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { openDb, type DB } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { commitBill } from '../src/repos'
|
|
|
|
/**
|
|
* Item-master anchoring (red-team C2, C3, H5, H7 + MRP/unit guards). commitBill is the
|
|
* trust boundary: it rebuilds every line from OUR item row and treats any price the client
|
|
* couldn't have gotten from the master as a deviation that needs a bound token or the
|
|
* committer's own authority. Hermetic in-memory DB; seed items: 107 Amul Butter (master
|
|
* sale 27500, MRP 28000, GST12, whole unit), 42 Tomato loose (KG, 3 decimals).
|
|
*/
|
|
function fresh(): DB {
|
|
const db = openDb(':memory:')
|
|
seedIfEmpty(db)
|
|
return db
|
|
}
|
|
const itemId = (db: DB, code: string): string =>
|
|
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
|
|
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
|
|
|
const line = (id: string, over: Partial<{ unitPricePaise: number; taxClassCode: string; qty: number }> = {}) => ({
|
|
itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: over.qty ?? 1, unitCode: 'PCS',
|
|
unitPricePaise: over.unitPricePaise ?? 27_500, priceIncludesTax: true, taxClassCode: over.taxClassCode ?? 'GST12',
|
|
})
|
|
const bill = (cashierId: string, l: ReturnType<typeof line>, payable: number): Parameters<typeof commitBill>[1] => ({
|
|
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId, shiftId: 'sh1', businessDate: '2026-07-10',
|
|
lines: [l], payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
|
|
})
|
|
|
|
describe('item-master anchoring', () => {
|
|
it('commits a normal bill at the master price (happy path)', () => {
|
|
const db = fresh()
|
|
const out = commitBill(db, bill('u1', line(itemId(db, '107')), 27_500))
|
|
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9); business date 2026-07-10 → FY 2026-27
|
|
expect(billCount(db)).toBe(1)
|
|
})
|
|
|
|
it('C2: a cashier underpricing a ₹275 item to ₹1 without approval is rejected (4xx)', () => {
|
|
const db = fresh()
|
|
const err = (() => { try { commitBill(db, bill('u1', line(itemId(db, '107'), { unitPricePaise: 100 }), 100)); return undefined } catch (e) { return e as Error & { status?: number } } })()
|
|
expect(err).toBeDefined()
|
|
expect(err!.status).toBeGreaterThanOrEqual(400)
|
|
expect(err!.status).toBeLessThan(500)
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('C3: a forged ZERO tax class is ignored — the bill is taxed at the master GST12 rate', () => {
|
|
const db = fresh()
|
|
// Inclusive price, so the ₹275 total is identical under ZERO or GST12; only the split
|
|
// differs. The server anchors GST12, so the committed bill still carries CGST+SGST.
|
|
commitBill(db, bill('u1', line(itemId(db, '107'), { taxClassCode: 'ZERO' }), 27_500))
|
|
const b = db.prepare(`SELECT cgst_paise, sgst_paise, taxable_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number; taxable_paise: number }
|
|
expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
|
|
expect(b.taxable_paise).toBeLessThan(27_500) // some of the sticker price is GST, not taxable base
|
|
})
|
|
|
|
it('H5: a line for a non-existent item is rejected, nothing committed', () => {
|
|
const db = fresh()
|
|
expect(() => commitBill(db, bill('u1', line('DOES-NOT-EXIST'), 27_500))).toThrow(/E-1105|catalogue/i)
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('H7: an owner self-authorises a price change and it is audited from their identity', () => {
|
|
const db = fresh()
|
|
commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 25_000 }), 25_000)) // u3 = owner
|
|
const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'`).get() as { after_json: string } | undefined
|
|
expect(audit).toBeDefined()
|
|
const after = JSON.parse(audit!.after_json) as { approvedByUserId: string; selfAuthorized?: boolean }
|
|
expect(after.approvedByUserId).toBe('u3')
|
|
expect(after.selfAuthorized).toBe(true)
|
|
expect(billCount(db)).toBe(1)
|
|
})
|
|
|
|
it('hard-blocks a price above MRP even for an owner (E-1301)', () => {
|
|
const db = fresh()
|
|
expect(() => commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 30_000 }), 30_000))).toThrow(/E-1301|MRP/i)
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
|
|
it('rejects a fractional quantity on a whole-unit item (E-1104)', () => {
|
|
const db = fresh()
|
|
expect(() => commitBill(db, bill('u1', line(itemId(db, '107'), { qty: 1.5 }), 41_300))).toThrow(/E-1104|whole/i)
|
|
expect(billCount(db)).toBe(0)
|
|
})
|
|
})
|