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/hq/test/ledger-outstanding.test.ts

98 lines
5.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.

// apps/hq/test/ledger-outstanding.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, markStatus } from '../src/repos-documents'
import { recordPayment, clientLedger } from '../src/repos-payments'
/**
* Fixture: one client, one module priced ₹10,000/yr (→ ₹11,800 with GST18).
* - INVOICE (qty 1): issued as INV/26-27-0001, payable ₹11,800.
* Partly paid ₹5,000 → outstanding ₹6,800.
* - PROFORMA (qty 2): issued as PI/26-27-0001, payable ₹23,600, marked 'sent'
* (open — nothing paid against a proforma).
*/
async function buildFixture(db: ReturnType<typeof openDb>) {
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id) // payable ₹11,800 (taxable ₹10,000 + GST18 ₹1,800)
const pf = await markStatus(db, 'u1', (await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
})).id)).id, 'sent') // payable ₹23,600 (taxable ₹20,000 + GST18 ₹3,600)
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 5_000_00,
}) // ₹5,000 of ₹11,800 → outstanding ₹6,800
return { clientId: c.id, inv, pf, payment }
}
describe('clientLedger — outstanding + per-payment allocations', () => {
it('reports outstanding open documents oldest-first with correct due amounts', async () => {
const db = openDb(':memory:')
const { clientId, inv, pf } = await buildFixture(db)
const led = await clientLedger(db, clientId)
// INV/26-27-0001 sorts before PI/26-27-0001 (docNo lexical order, both oldest).
expect(led.outstanding.map((o) => o.docNo)).toEqual([inv.docNo, pf.docNo])
expect(led.outstanding[0]).toMatchObject({
docId: inv.id, docNo: inv.docNo, docType: 'INVOICE', label: 'POS',
outstandingPaise: 6_800_00, // 11,800 payable 5,000 allocated 0 credited
})
expect(led.outstanding[1]).toMatchObject({
docId: pf.id, docNo: pf.docNo, docType: 'PROFORMA', label: 'POS',
outstandingPaise: 23_600_00, // open proforma: full payable, nothing settled against it
})
})
it('excludes a fully-paid invoice and a cancelled/lost document from outstanding', async () => {
const db = openDb(':memory:')
const { clientId, inv } = await buildFixture(db)
// Settle the remaining 6,800 in full.
await recordPayment(db, 'u1', { clientId, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 6_800_00 })
const led = await clientLedger(db, clientId)
expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined()
})
it('excludes a draft proforma from outstanding but still includes a sent one', async () => {
const db = openDb(':memory:')
const { clientId, inv, pf } = await buildFixture(db)
const draftPf = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId, lines: [{ moduleId: inv.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }],
}) // left as 'draft' — never issued/sent
const led = await clientLedger(db, clientId)
expect(led.outstanding.find((o) => o.docId === draftPf.id)).toBeUndefined()
expect(led.outstanding.find((o) => o.docId === pf.id)).toMatchObject({ docId: pf.id, docNo: pf.docNo })
})
it('attaches allocations to each payment', async () => {
const db = openDb(':memory:')
const { clientId, inv, payment } = await buildFixture(db)
const led = await clientLedger(db, clientId)
const p = led.payments.find((x) => x.id === payment.id)!
expect(p.allocations).toEqual([{ docNo: inv.docNo, amountPaise: 5_000_00 }])
expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise)
})
it('a payment with no allocation (pure advance) reports an empty allocations array', async () => {
const db = openDb(':memory:')
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'NoInvoiceCo', stateCode: '32' })
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 1_000_00,
})
const led = await clientLedger(db, c.id)
expect(led.payments[0]!.id).toBe(payment.id)
expect(led.payments[0]!.allocations).toEqual([])
expect(led.outstanding).toEqual([])
})
})