Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/api/src/bills-interest.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'

const { fakeDb, setQueue } = vi.hoisted(() => {
let queue: unknown[][] = []
const makeChain = (): unknown =>
new Proxy(function () {}, {
get(_t, prop) {
if (prop === 'then') {
const r = queue.length ? (queue.shift() as unknown[]) : []
return (resolve: (v: unknown) => void) => resolve(r)
}
return () => makeChain()
},
apply: () => makeChain(),
})
return { fakeDb: () => makeChain(), setQueue: (q: unknown[][]) => (queue = q) }
})

vi.mock('@opensociety/db', async (orig) => ({
...(await orig<typeof import('@opensociety/db')>()),
createDb: () => fakeDb(),
}))

const { billRoutes } = await import('./routes/bills')
import type { AppEnv } from './types'

const env = { DATABASE_URL: 'test' } as AppEnv['Bindings']
const ADMIN = { id: 'a1', role: 'ADMIN', status: 'APPROVED' }
const RESIDENT = { id: 'r1', role: 'RESIDENT', status: 'APPROVED' }
const req = (path: string, id: string | undefined, method = 'GET') =>
billRoutes.request(path, { method, headers: id ? { 'x-user-id': id } : {} }, env)

beforeEach(() => setQueue([]))

describe('interest routes — admin only', () => {
it('403s a resident on POST /apply-interest', async () => {
setQueue([[RESIDENT]])
expect((await req('/apply-interest', 'r1', 'POST')).status).toBe(403)
})

it('400s apply-interest when interest is disabled in config', async () => {
setQueue([[ADMIN], [{ id: 'c1', interestEnabled: false, interestRatePct: 18, gracePeriodDays: 15 }]])
const res = await req('/apply-interest', 'a1', 'POST')
expect(res.status).toBe(400)
})

it('returns an empty preview when no config exists', async () => {
setQueue([[ADMIN], []])
const res = await req('/interest-preview', 'a1')
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ enabled: false, rows: [] })
})

it('403s a resident on POST /:id/cancel', async () => {
setQueue([[RESIDENT]])
expect((await req('/some-bill-id/cancel', 'r1', 'POST')).status).toBe(403)
})
})
49 changes: 49 additions & 0 deletions apps/api/src/interest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { computeInterest, sumApartmentInterest } from '@opensociety/shared'

const day = (y: number, m: number, d: number) => Date.UTC(y, m - 1, d)

describe('computeInterest — simple interest on arrears', () => {
it('is zero on the due date (no elapsed days)', () => {
expect(computeInterest(100000, day(2026, 1, 1), day(2026, 1, 1), 12, 0)).toBe(0)
})

it('accrues a full year at the annual rate', () => {
// ₹1000 @ 12% for 365 days = ₹120 = 12000 paise
expect(computeInterest(100000, day(2026, 1, 1), day(2027, 1, 1), 12, 0)).toBe(12000)
})

it('accrues pro-rata for part of a year', () => {
// 30 days: round(100000*12*30/36500) = 986
expect(computeInterest(100000, day(2026, 1, 1), day(2026, 1, 31), 12, 0)).toBe(986)
})

it('respects the grace period', () => {
// 9 days elapsed, 15-day grace -> still 0
expect(computeInterest(100000, day(2026, 1, 1), day(2026, 1, 10), 12, 15)).toBe(0)
// 20 days elapsed, 15-day grace -> 5 chargeable days
expect(computeInterest(100000, day(2026, 1, 1), day(2026, 1, 21), 12, 15)).toBe(
Math.round((100000 * 12 * 5) / 36500),
)
})

it('is zero for a cleared balance or a disabled rate', () => {
expect(computeInterest(0, day(2026, 1, 1), day(2027, 1, 1), 12, 0)).toBe(0)
expect(computeInterest(100000, day(2026, 1, 1), day(2027, 1, 1), 0, 0)).toBe(0)
expect(computeInterest(-5000, day(2026, 1, 1), day(2027, 1, 1), 12, 0)).toBe(0)
})
})

describe('sumApartmentInterest', () => {
it('sums interest across a flat’s overdue bills', () => {
const asOf = day(2027, 1, 1)
const bills = [
{ outstanding: 100000, dueDateMs: day(2026, 1, 1) }, // full year -> 12000
{ outstanding: 50000, dueDateMs: day(2026, 7, 1) }, // ~184 days
]
const expected =
computeInterest(100000, day(2026, 1, 1), asOf, 12, 0) + computeInterest(50000, day(2026, 7, 1), asOf, 12, 0)
expect(sumApartmentInterest(bills, asOf, 12, 0)).toBe(expected)
expect(sumApartmentInterest(bills, asOf, 12, 0)).toBeGreaterThan(12000)
})
})
49 changes: 49 additions & 0 deletions apps/api/src/lib/ledger-posting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
postPaymentReceived as buildPaymentEntry,
postExpense as buildExpenseEntry,
postExpenseSettlement as buildSettlementEntry,
reverseLines,
splitReceivableAdvance,
validateEntryLines,
type JournalDraft,
type JournalLineInput,
type BillPostingLine,
type PaymentMethod,
type ExpenseStatus,
Expand Down Expand Up @@ -55,6 +57,8 @@ export async function insertJournalEntry(db: Database, draft: JournalDraft, crea
sourceType: draft.sourceType,
sourceId: draft.sourceId ?? null,
period: draft.period,
isReversal: draft.isReversal ?? false,
reversesId: draft.reversesId ?? null,
createdBy: createdBy ?? null,
})
.returning({ id: journalEntries.id })
Expand Down Expand Up @@ -266,6 +270,51 @@ export async function safeSettleExpense(
}
}

// §6.9 — reverse the "bill issued" entry when a bill is cancelled. Posts an
// ADJUSTMENT with swapped debit/credit that references the original entry. No-op
// if the bill was never posted or is already reversed (best-effort).
export async function safeReverseBill(db: Database, billId: string, createdBy?: string): Promise<void> {
try {
const [orig] = await db
.select({ id: journalEntries.id, entryDate: journalEntries.entryDate, period: journalEntries.period })
.from(journalEntries)
.where(and(eq(journalEntries.sourceType, 'BILL'), eq(journalEntries.sourceId, billId)))
.limit(1)
if (!orig) return
const [{ already }] = await db
.select({ already: sql<number>`count(*)::int` })
.from(journalEntries)
.where(eq(journalEntries.reversesId, orig.id))
if (Number(already) > 0) return // already reversed

const lines = await db
.select({ accountId: journalLines.accountId, debit: journalLines.debit, credit: journalLines.credit, apartmentId: journalLines.apartmentId })
.from(journalLines)
.where(eq(journalLines.entryId, orig.id))
if (lines.length === 0) return

const reversed: JournalLineInput[] = reverseLines(
lines.map((l) => ({ accountId: l.accountId, debit: l.debit, credit: l.credit, apartmentId: l.apartmentId })),
)
await insertJournalEntry(
db,
{
entryDate: orig.entryDate,
narration: 'Bill cancelled — reversal',
sourceType: 'ADJUSTMENT',
sourceId: billId,
period: orig.period,
isReversal: true,
reversesId: orig.id,
lines: reversed,
},
createdBy,
)
} catch (e) {
console.error('ledger: failed to reverse bill', billId, e)
}
}

// Best-effort wrappers: log and swallow so a ledger hiccup never fails the
// underlying bill/payment write.
export async function safePostBill(db: Database, billId: string, createdBy?: string): Promise<void> {
Expand Down
32 changes: 29 additions & 3 deletions apps/api/src/routes/bill-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ billConfigRoutes.use('*', requireRole('ADMIN'))

billConfigRoutes.get('/', async (c) => {
const [cfg] = await c.get('db').select().from(billConfig).limit(1)
return c.json(cfg ?? { id: null, dueDayOfMonth: 10, lineItems: [], updatedBy: null, updatedAt: null })
return c.json(
cfg ?? {
id: null,
dueDayOfMonth: 10,
lineItems: [],
interestEnabled: false,
interestRatePct: 18,
gracePeriodDays: 15,
updatedBy: null,
updatedAt: null,
},
)
})

billConfigRoutes.put('/', zValidator('json', updateBillConfigSchema), async (c) => {
Expand All @@ -25,14 +36,29 @@ billConfigRoutes.put('/', zValidator('json', updateBillConfigSchema), async (c)
if (existing) {
const [updated] = await db
.update(billConfig)
.set({ dueDayOfMonth: input.dueDayOfMonth, lineItems: input.lineItems, updatedBy: actingUserId(c), updatedAt: new Date() })
.set({
dueDayOfMonth: input.dueDayOfMonth,
lineItems: input.lineItems,
interestEnabled: input.interestEnabled,
interestRatePct: input.interestRatePct,
gracePeriodDays: input.gracePeriodDays,
updatedBy: actingUserId(c),
updatedAt: new Date(),
})
.where(eq(billConfig.id, existing.id))
.returning()
return c.json(updated)
}
const [created] = await db
.insert(billConfig)
.values({ dueDayOfMonth: input.dueDayOfMonth, lineItems: input.lineItems, updatedBy: actingUserId(c) })
.values({
dueDayOfMonth: input.dueDayOfMonth,
lineItems: input.lineItems,
interestEnabled: input.interestEnabled,
interestRatePct: input.interestRatePct,
gracePeriodDays: input.gracePeriodDays,
updatedBy: actingUserId(c),
})
.returning()
return c.json(created, 201)
})
Loading
Loading