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/apps/store-server/test/gst.test.ts

222 lines
11 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 { afterEach, describe, expect, it } from 'vitest'
import express from 'express'
import type { AddressInfo } from 'node:net'
import type { Server } from 'node:http'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill, createCustomer } from '../src/repos'
import { commitReturn } from '../src/repos-returns'
import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from '../src/repos-gst'
import { apiRouter } from '../src/api'
/**
* GST returns — READ-ONLY compliance reporting over the period's immutable documents. These lock:
* the B2B/B2CS split by buyer GSTIN, B2CS aggregation by rate, a credit note reducing its rate
* bucket, HSN totals, the GSTR-3B 3.1(a) outward figures hand-checked against the bills, the
* reconciliation identity (sections == source == HSN == 3B), the REPORT_VIEW gate, and valid JSON.
*
* Hermetic in-memory seed: item 103 Surf Excel (GST18, ₹145 incl), 107 Amul Butter (GST12, ₹275),
* 104 Parle-G (GST5, ₹90). Store state 32. u1 Ramesh/cashier (4728), u3 Thomas/owner (9174).
*/
const PERIOD = '2026-07'
const DATE = '2026-07-10'
const CTIN = '27ABCDE1234F1Z0' // registered buyer in state 27 → inter-state B2B (IGST)
function itemId(db: DB, code: string): string {
return (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
}
interface Built { custA: string; bill1: string; bill2: string; bill3: string }
/** Build the known document set for a single month and return the ids. */
function buildMonth(db: DB): Built {
const custA = createCustomer(db, 't1', 'Anand Traders', '9876543210', { gstin: CTIN }).id
const line = (code: string, qty: number, price: number, cls: string, hsn: string, name: string) => ({
itemId: itemId(db, code), name, hsn, qty, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: cls,
})
const commit = (lines: ReturnType<typeof line>[], payable: number, customerId?: string) =>
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
...(customerId !== undefined ? { customerId } : {}),
})
// Bill 1 — B2B: 2 × Surf Excel to the GSTIN buyer (inter-state → IGST).
const bill1 = commit([line('103', 2, 14_500, 'GST18', '3402', 'Surf Excel 1kg')], 29_000, custA).id
// Bill 2 — B2C cash: 1 × Surf Excel + 1 × Amul Butter (intra → CGST/SGST).
const bill2 = commit([line('103', 1, 14_500, 'GST18', '3402', 'Surf Excel 1kg'), line('107', 1, 27_500, 'GST12', '0405', 'Amul Butter 500g')], 42_000).id
// Bill 3 — B2C cash: 3 × Parle-G.
const bill3 = commit([line('104', 3, 9_000, 'GST5', '1905', 'Parle-G 800g')], 27_000).id
// Return — the full Amul line from bill 2 (index 1) → nets into B2CS at GST12.
commitReturn(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
originalBillId: bill2, lines: [{ lineIndex: 1, qty: 1 }], reason: 'Damaged', refundMode: 'CASH',
})
return { custA, bill1, bill2, bill3 }
}
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
describe('gstr1 — sections over the known month', () => {
it('B2B shows the GSTIN invoice; B2CS aggregates the cash bills by rate; the return nets out', () => {
const db = fresh()
buildMonth(db)
const r = gstr1(db, 't1', PERIOD)
// B2B: one party, one invoice, GST18, taxable 24576 + IGST 4424 (₹290 incl of 2×₹145).
expect(r.b2b).toHaveLength(1)
expect(r.b2b[0]!.ctin).toBe(CTIN)
expect(r.b2b[0]!.invoiceCount).toBe(1)
expect(r.b2b[0]!.totals.taxablePaise).toBe(24_576)
expect(r.b2b[0]!.totals.igstPaise).toBe(4_424)
// B2CS: Surf (GST18) and Parle (GST5) survive; the Amul (GST12) bucket nets to zero and is dropped.
const b18 = r.b2cs.find((b) => b.rateBp === 1800)!
expect(b18.taxablePaise).toBe(12_288)
expect(b18.cgstPaise).toBe(1_106)
expect(r.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(25_714)
expect(r.b2cs.some((b) => b.rateBp === 1200)).toBe(false)
// No B2CL / CDNR / CDNUR in this set (nothing large, no registered return).
expect(r.b2cl).toHaveLength(0)
expect(r.cdnr).toHaveLength(0)
expect(r.cdnur).toHaveLength(0)
// HSN: Surf 3402 aggregates B2B + B2C (qty 3); Amul 0405 nets to zero and drops.
const surf = r.hsn.find((h) => h.hsn === '3402')!
expect(surf.taxablePaise).toBe(36_864)
expect(surf.qty).toBe(3)
expect(r.hsn.some((h) => h.hsn === '0405')).toBe(false)
expect(r.totals.hsn.taxablePaise).toBe(62_578)
// Documents issued: 3 invoices + 1 credit note.
expect(r.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!.totalNumber).toBe(3)
expect(r.docs.find((d) => d.natureOfDocument === 'Credit notes')!.totalNumber).toBe(1)
})
})
describe('gstr3b — 3.1(a) hand-checked against the bills', () => {
it('outward = Σ bill tax credit-note tax, at captured rates', () => {
const db = fresh()
buildMonth(db)
const r = gstr3b(db, 't1', PERIOD)
// Sales tax: IGST 4424 + CGST 3222 + SGST 3222 = 10868; credit note: CGST 1473 + SGST 1473 = 2946.
expect(r.salesTax.igstPaise + r.salesTax.cgstPaise + r.salesTax.sgstPaise).toBe(10_868)
expect(r.returnsTax.cgstPaise + r.returnsTax.sgstPaise).toBe(2_946)
expect(r.outward.taxablePaise).toBe(62_578)
expect(r.outward.igstPaise).toBe(4_424)
expect(r.outward.cgstPaise).toBe(1_749)
expect(r.outward.sgstPaise).toBe(1_749)
expect(r.outward.igstPaise + r.outward.cgstPaise + r.outward.sgstPaise).toBe(7_922) // 10868 2946
expect(r.itc.igstPaise).toBe(0) // ITC stub
})
})
describe('reconciliation — the compliance gate ties out', () => {
it('GSTR-1 sections == source == HSN == GSTR-3B (taxable and tax)', () => {
const db = fresh()
buildMonth(db)
const r = gstr1(db, 't1', PERIOD)
expect(r.reconciliation.ok).toBe(true)
expect(r.reconciliation.mismatches).toEqual([])
expect(r.reconciliation.sourceTaxablePaise).toBe(62_578)
expect(r.reconciliation.gstr1NetTaxablePaise).toBe(62_578)
expect(r.reconciliation.hsnTaxablePaise).toBe(62_578)
expect(r.reconciliation.gstr3bTaxablePaise).toBe(62_578)
expect(r.reconciliation.sourceTaxPaise).toBe(7_922)
expect(r.reconciliation.gstr3bTaxPaise).toBe(7_922)
})
it('an empty period reconciles to zero, not an error', () => {
const db = fresh()
const r = gstr1(db, 't1', '2026-01')
expect(r.reconciliation.ok).toBe(true)
expect(r.reconciliation.sourceTaxablePaise).toBe(0)
expect(r.b2b).toHaveLength(0)
expect(r.docs).toHaveLength(0)
})
})
describe('gstr1PortalJson — pragmatic GSTN-shaped export', () => {
it('produces valid, serialisable JSON with the seller GSTIN and section keys', () => {
const db = fresh()
buildMonth(db)
const j = gstr1PortalJson(db, 't1', PERIOD)
// Round-trips through JSON.stringify/parse (no NaN, no cycles) and carries the shape.
const round = JSON.parse(JSON.stringify(j)) as Record<string, unknown>
expect(round['gstin']).toBe('32ABCDE1234F1Z5')
expect(round['fp']).toBe('072026')
expect(Array.isArray(round['b2b'])).toBe(true)
expect((round['b2b'] as unknown[]).length).toBe(1)
expect(round['_note']).toMatch(/NOT a certified/i)
const hsn = round['hsn'] as { data: unknown[] }
expect(Array.isArray(hsn.data)).toBe(true)
})
})
// ---- HTTP surface: the REPORT_VIEW gate + period validation ----
interface Harness { db: DB; url: string; server: Server }
async function start(): Promise<Harness> {
const db = openDb(':memory:')
seedIfEmpty(db)
buildMonth(db)
const app = express()
app.use(express.json({ limit: '1mb' }))
app.use('/api', apiRouter(db))
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
const status = typeof e.status === 'number' ? e.status : 500
if (res.headersSent) return
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
})
const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
const port = (server.address() as AddressInfo).port
return { db, url: `http://127.0.0.1:${port}`, server }
}
async function login(url: string, userId: string, pin: string): Promise<string> {
const res = await fetch(`${url}/api/auth/pin`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
})
const body = (await res.json()) as { token?: string }
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
return body.token
}
const get = (url: string, path: string, token?: string) =>
fetch(`${url}${path}`, { headers: { ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) } })
describe('GST HTTP routes — REPORT_VIEW gate + validation', () => {
let h: Harness
afterEach(() => { h.server.close() })
it('owner (REPORT_VIEW) reads the returns; a cashier is 403; unauth is 401; a bad period is 400', async () => {
h = await start()
// unauthenticated
expect((await get(h.url, `/api/gst/gstr1?period=${PERIOD}`)).status).toBe(401)
// a cashier lacks REPORT_VIEW → 403 E-6105
const cashier = await login(h.url, 'u1', '4728')
const denied = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, cashier)
expect(denied.status).toBe(403)
expect(((await denied.json()) as { code?: string }).code).toBe('E-6105')
// the owner reads it → 200 with the reconciled numbers
const owner = await login(h.url, 'u3', '9174')
const ok = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, owner)
expect(ok.status).toBe(200)
const body = (await ok.json()) as { reconciliation: { ok: boolean }; totals: { net: { taxablePaise: number } } }
expect(body.reconciliation.ok).toBe(true)
expect(body.totals.net.taxablePaise).toBe(62_578)
// 3B + hsn + json all 200 for the owner
expect((await get(h.url, `/api/gst/gstr3b?period=${PERIOD}`, owner)).status).toBe(200)
expect((await get(h.url, `/api/gst/hsn?period=${PERIOD}`, owner)).status).toBe(200)
const j = await get(h.url, `/api/gst/gstr1.json?period=${PERIOD}`, owner)
expect(j.status).toBe(200)
expect(((await j.json()) as { fp?: string }).fp).toBe('072026')
// a malformed period → 400 E-1330
const bad = await get(h.url, `/api/gst/gstr1?period=2026-13`, owner)
expect(bad.status).toBe(400)
expect(((await bad.json()) as { code?: string }).code).toBe('E-1330')
})
})