import { describe, expect, it } from 'vitest' import type { BillTotals } from '@sims/domain' import type { LineInput } from '@sims/billing-engine' import { buildQueuedBill, nextProvisionalToken, toOfflineBillBody, type QueuedBill } from '../src/offline' const TOTALS: BillTotals = { grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288, cgstPaise: 1_106, sgstPaise: 1_106, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 14_500, savingsVsMrpPaise: 500, } const LINES: LineInput[] = [{ itemId: 'i-103', name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18', }] describe('S4 offline queue — provisional tokens', () => { it('formats OFF-n from the sequence', () => { expect(nextProvisionalToken(1)).toBe('OFF-1') expect(nextProvisionalToken(42)).toBe('OFF-42') }) }) describe('S4 offline queue — buildQueuedBill', () => { const base = { seq: 2, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10', lines: LINES, payments: [{ mode: 'CASH', amountPaise: 14_500 }], totals: TOTALS, now: 1_700_000_000_000, } it('assigns a client uuidv7 id, a matching provisional token, and an ISO createdAt', () => { const q = buildQueuedBill(base) expect(q.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(q.provisionalToken).toBe('OFF-2') expect(q.seq).toBe(2) expect(q.createdAt).toBe(new Date(1_700_000_000_000).toISOString()) expect(q.totals.payablePaise).toBe(14_500) }) it('omits optional customer / override fields when absent', () => { const q = buildQueuedBill(base) expect('customerId' in q).toBe(false) expect('customerName' in q).toBe(false) expect('buyerGstin' in q).toBe(false) expect('overrides' in q).toBe(false) }) it('keeps only the customer id and non-empty overrides — never customer PII (M11)', () => { const q = buildQueuedBill({ ...base, customerId: 'p1', overrides: [{ itemId: 'i-103', kind: 'price', before: 15_000, after: 14_500, approvalId: 'a1' }], }) expect(q.customerId).toBe('p1') // M11: the queued record must NOT carry the buyer's name or GSTIN — the server // re-resolves both from customerId at drain, so no PII lands on the shared counter PC. expect('customerName' in q).toBe(false) expect('buyerGstin' in q).toBe(false) expect(q.overrides).toHaveLength(1) }) it('drops an empty overrides array rather than persisting it', () => { const q = buildQueuedBill({ ...base, overrides: [] }) expect('overrides' in q).toBe(false) }) }) describe('S4 offline queue — toOfflineBillBody', () => { it('maps to the drain payload: keeps the client id, sets clientPayablePaise, drops display-only fields', () => { const q: QueuedBill = buildQueuedBill({ seq: 1, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', customerId: 'p1', businessDate: '2026-07-10', lines: LINES, payments: [{ mode: 'CASH', amountPaise: 14_500 }], totals: TOTALS, }) const body = toOfflineBillBody(q) expect(body.id).toBe(q.id) expect(body.clientPayablePaise).toBe(14_500) expect(body.customerId).toBe('p1') // SEC fix: cashier/tenant come from the server session, never the body expect('cashierId' in body).toBe(false) // M11: no customer PII is stored offline, so none can travel in the drain body expect('customerName' in body).toBe(false) expect('buyerGstin' in body).toBe(false) expect('totals' in body).toBe(false) expect('provisionalToken' in body).toBe(false) }) })