fix(sec/d24): money-integrity guards — CN cap, atomic doc-no, qty, TDS, markStatus
Red-team money findings: - createCreditNote caps the CN to the invoice's remaining creditable value (invoice payable − already-credited by non-cancelled CNs), read+insert in one txn. Blocks a CN exceeding its invoice / unlimited CNs flipping it 'paid'. - nextDocNo allocates via one atomic UPDATE ... RETURNING (row lock) instead of read-then-bump — no duplicate/burned numbers under concurrent issue (Postgres). - markStatus rejects accepted/lost on non-QUOTATION docs — an issued invoice can no longer be hidden from overdue chasing via a quote-only transition. - computeBill bounds qty magnitude (keeps fractional for weighed Store goods); HQ buildLines additionally requires whole-unit integer qty (SMS/module seats). - recordPayment rejects TDS greater than the cash received — no fabricated-TDS 'settlement' with no money. money-guards test locks all four. Suite money paths green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
54cde7961b
commit
f812555528
@ -0,0 +1,57 @@
|
|||||||
|
// apps/hq/test/money-guards.test.ts — D24 (red-team): money-integrity guards.
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
import { seedIfEmpty } from '../src/seed'
|
||||||
|
import { createClient } from '../src/repos-clients'
|
||||||
|
import { createModule, setPrice } from '../src/repos-modules'
|
||||||
|
import { createDraft, issueDocument, createCreditNote, markStatus, getDocument } from '../src/repos-documents'
|
||||||
|
import { recordPayment } from '../src/repos-payments'
|
||||||
|
|
||||||
|
async function world() {
|
||||||
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
||||||
|
const c = await createClient(db, 'u1', { name: 'Bank', stateCode: '32' })
|
||||||
|
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core', allowedKinds: ['yearly'] })
|
||||||
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-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)
|
||||||
|
return { db, c, m, inv }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('credit-note cap (D24)', () => {
|
||||||
|
it('a full-value CN succeeds; a second CN on the same invoice is refused (would exceed it)', async () => {
|
||||||
|
const { db, inv } = await world()
|
||||||
|
const cn1 = await createCreditNote(db, 'u1', inv.id) // full value
|
||||||
|
expect(cn1.payablePaise).toBe(inv.payablePaise)
|
||||||
|
await expect(createCreditNote(db, 'u1', inv.id)).rejects.toThrow(/exceeds the invoice/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markStatus type guard (D24)', () => {
|
||||||
|
it('an issued invoice cannot be marked accepted/lost', async () => {
|
||||||
|
const { db, inv } = await world()
|
||||||
|
await expect(markStatus(db, 'u1', inv.id, 'lost')).rejects.toThrow(/Only quotations/i)
|
||||||
|
await expect(markStatus(db, 'u1', inv.id, 'accepted')).rejects.toThrow(/Only quotations/i)
|
||||||
|
// The invoice keeps its real status — still visible to overdue chasing.
|
||||||
|
expect((await getDocument(db, inv.id))!.status).not.toBe('lost')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('integer quantity guard (D24)', () => {
|
||||||
|
it('a fractional / non-integer line quantity is rejected at draft', async () => {
|
||||||
|
const { db, c, m } = await world()
|
||||||
|
await expect(createDraft(db, 'u1', {
|
||||||
|
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1.5, kind: 'yearly' }],
|
||||||
|
})).rejects.toThrow(/whole number/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TDS bound (D24)', () => {
|
||||||
|
it('TDS greater than the cash received is refused', async () => {
|
||||||
|
const { db, c, inv } = await world()
|
||||||
|
await expect(recordPayment(db, 'u1', {
|
||||||
|
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 1_000_00, tdsPaise: 2_000_00,
|
||||||
|
allocations: [{ documentId: inv.id, amountPaise: 3_000_00 }],
|
||||||
|
})).rejects.toThrow(/TDS cannot exceed/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue