|
|
// 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('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([])
|
|
|
})
|
|
|
})
|