diff --git a/src/app/api/accounting/accounts/[id]/balance/route.ts b/src/app/api/accounting/accounts/[id]/balance/route.ts new file mode 100644 index 0000000..139afe1 --- /dev/null +++ b/src/app/api/accounting/accounts/[id]/balance/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +/** + * GET /api/accounting/accounts/[id]/balance + * + * Returns the derived balance for an account, computed by summing POSTED journal entry lines. + * Balances are NEVER stored — always derived on demand. + * + * Query params: + * ?asOfDate=ISO8601 — compute balance as of this date (defaults to all-time) + * ?startDate=ISO8601 — include entries on or after this date (defaults to all-time) + * + * Requires: read on Account (ADMIN or OFFICE_STAFF). + * + * Response: + * 200 OK — { accountId, balance, asOfDate } + * 400 Bad Request — no tenant context + * 404 Not Found — account not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Account")( + async (innerReq: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const { searchParams } = new URL(innerReq.url); + const asOfDateParam = searchParams.get("asOfDate"); + const startDateParam = searchParams.get("startDate"); + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const result = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: id, + asOfDate: asOfDateParam ? new Date(asOfDateParam) : undefined, + startDate: startDateParam ? new Date(startDateParam) : undefined, + }); + + return NextResponse.json({ + accountId: result.accountId, + balance: result.balance.toFixed(2), + asOfDate: result.asOfDate ? result.asOfDate.toISOString() : null, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get account balance"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } + } + )(req); +} diff --git a/src/app/api/accounting/journal-entries/[id]/approve/route.ts b/src/app/api/accounting/journal-entries/[id]/approve/route.ts new file mode 100644 index 0000000..69ffb01 --- /dev/null +++ b/src/app/api/accounting/journal-entries/[id]/approve/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +/** + * POST /api/accounting/journal-entries/[id]/approve + * + * Approves a manual journal entry (DRAFT -> POSTED). + * Implements maker-checker workflow. Self-approval is allowed for single-person operations. + * + * Requires: manage on Account (ADMIN only). + * + * Response: + * 200 OK — updated journal entry with lines + * 400 Bad Request — entry already posted or invalid state + * 404 Not Found — entry not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Account")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const entry = await JournalEntryService.approveEntry({ + tenantPrisma, + entryId: id, + approvedById: user.id, + }); + + return NextResponse.json(entry); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to approve journal entry"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } + } + )(req); +} diff --git a/src/app/api/accounting/journal-entries/[id]/reverse/route.ts b/src/app/api/accounting/journal-entries/[id]/reverse/route.ts new file mode 100644 index 0000000..a3d0a21 --- /dev/null +++ b/src/app/api/accounting/journal-entries/[id]/reverse/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +/** + * POST /api/accounting/journal-entries/[id]/reverse + * + * Reverses a posted journal entry by creating a new entry with swapped debits/credits. + * The original entry is marked REVERSED. + * + * Body (optional): + * { + * date?: ISO8601 string — accounting date for the reversing entry (defaults to today) + * description?: string — description for the reversing entry + * } + * + * Requires: manage on Account (ADMIN only). + * + * Response: + * 201 Created — the new reversing entry with lines + * 400 Bad Request — entry already reversed or invalid + * 404 Not Found — entry not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Account")( + async (innerReq: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + let body: { date?: string; description?: string } = {}; + try { + const text = await innerReq.text(); + if (text.trim()) { + body = JSON.parse(text); + } + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + try { + const reversingEntry = await JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId: user.tenantId, + entryId: id, + reversedById: user.id, + date: body.date ? new Date(body.date) : undefined, + description: body.description, + }); + + return NextResponse.json(reversingEntry, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to reverse journal entry"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } + } + )(req); +} diff --git a/src/app/api/accounting/journal-entries/[id]/route.ts b/src/app/api/accounting/journal-entries/[id]/route.ts new file mode 100644 index 0000000..cac4a6e --- /dev/null +++ b/src/app/api/accounting/journal-entries/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; + +/** + * GET /api/accounting/journal-entries/[id] + * + * Returns a single journal entry with its lines. + * + * Requires: read on Account (ADMIN or OFFICE_STAFF). + * + * Response: + * 200 OK — journal entry with lines + * 400 Bad Request — no tenant context + * 404 Not Found — entry not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Account")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + const entry = await tenantPrisma.journalEntry.findFirst({ + where: { id }, + include: { + lines: { + orderBy: { createdAt: "asc" }, + }, + }, + }); + + if (!entry) { + return NextResponse.json({ error: "Journal entry not found" }, { status: 404 }); + } + + return NextResponse.json(entry); + } + )(req); +} diff --git a/src/app/api/accounting/journal-entries/route.ts b/src/app/api/accounting/journal-entries/route.ts new file mode 100644 index 0000000..332d2aa --- /dev/null +++ b/src/app/api/accounting/journal-entries/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; +import { JournalEntrySource, JournalEntryStatus } from "@prisma/client"; + +/** + * GET /api/accounting/journal-entries + * + * Lists journal entries for the authenticated tenant. + * Ordered by date descending (most recent first). + * + * Query params: + * ?startDate=ISO8601 — filter entries on or after this date + * ?endDate=ISO8601 — filter entries on or before this date + * ?status=DRAFT|POSTED|REVERSED|... — filter by status + * ?source=SYSTEM|MANUAL — filter by source + * + * Requires: read on Account (ADMIN or OFFICE_STAFF). + * + * Response: + * 200 OK — Array of journal entries with lines + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const GET = withPermission("read", "Account")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + const status = searchParams.get("status") as JournalEntryStatus | null; + const source = searchParams.get("source") as JournalEntrySource | null; + + // Validate enum values + if (status && !Object.values(JournalEntryStatus).includes(status)) { + return NextResponse.json({ error: `Invalid status: ${status}` }, { status: 400 }); + } + if (source && !Object.values(JournalEntrySource).includes(source)) { + return NextResponse.json({ error: `Invalid source: ${source}` }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + // Build date filter + const dateFilter: Record = {}; + if (startDate) dateFilter.gte = new Date(startDate); + if (endDate) dateFilter.lte = new Date(endDate); + + const entries = await tenantPrisma.journalEntry.findMany({ + where: { + ...(Object.keys(dateFilter).length > 0 ? { date: dateFilter } : {}), + ...(status ? { status } : {}), + ...(source ? { source } : {}), + }, + include: { + lines: { + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { date: "desc" }, + }); + + return NextResponse.json(entries); + } +); + +/** + * POST /api/accounting/journal-entries + * + * Creates a manual journal entry. Requires ADMIN role. + * Manual entries start with status DRAFT (maker-checker workflow). + * + * Body: + * { + * date: ISO8601 string, + * description: string, + * lines: [{ accountId: string, debit: number, credit: number, description?: string }] + * } + * + * Requires: manage on Account (ADMIN only). + * + * Response: + * 201 Created — journal entry with lines + * 400 Bad Request — unbalanced entry, missing fields, invalid accountId + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export const POST = withPermission("manage", "Account")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: { + date?: string; + description?: string; + lines?: Array<{ + accountId: string; + debit: number | string; + credit: number | string; + description?: string; + }>; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { date, description, lines } = body; + + if (!date || !description || !lines || !Array.isArray(lines)) { + return NextResponse.json( + { error: "Missing required fields: date, description, lines" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId: user.tenantId, + date: new Date(date), + description, + lines, + source: JournalEntrySource.MANUAL, + createdById: user.id, + }); + + return NextResponse.json(entry, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create journal entry"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); diff --git a/src/lib/__tests__/journal-entry-service.test.ts b/src/lib/__tests__/journal-entry-service.test.ts new file mode 100644 index 0000000..46a463c --- /dev/null +++ b/src/lib/__tests__/journal-entry-service.test.ts @@ -0,0 +1,855 @@ +/** + * JournalEntryService Integration Tests + * + * Tests the JournalEntryService — the sole gateway to the accounting ledger. + * These tests require a live PostgreSQL database connection. + * + * WHAT IS TESTED: + * - Balanced entry (debit=credit) creates successfully + * - Unbalanced entry (debit!=credit) throws error + * - Entry with < 2 lines throws error + * - Entry with both debit and credit on same line throws error + * - Entry in closed period throws error + * - Entry in open period succeeds + * - JournalEntry has no update/delete methods (immutability by design) + * - Reversing entry creates new entry with swapped debits/credits + * - Reverse marks original as REVERSED + * - Cannot reverse an already-reversed entry + * - Reversing entry references original via reversesEntryId + * - Account balance computed from entry lines (not stored) + * - Balance respects normal balance direction (DEBIT vs CREDIT accounts) + * - Balance with asOfDate filters correctly + * - Trial balance: total debits = total credits + * - Manual entry created with status DRAFT + * - System entry created with status POSTED + * - Approve changes DRAFT to POSTED + * - Self-approve allowed (single-person operation) + * - Cannot approve already-POSTED entry + * - Entry numbering: JE-{YYYY}-0001 for first, increments correctly + * + * ISOLATION STRATEGY: + * One test tenant created in beforeAll. afterAll cleans up. + * Each test uses unique dates/accounts to avoid interference. + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { closePeriod, getOpenPeriod } from "@/lib/accounting/accounting-period"; +import { + JournalEntrySource, + JournalEntryStatus, + TenantStatus, +} from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TEST_TIMESTAMP = Date.now(); + +let tenantId: string; +let tenantPrisma: ReturnType; + +// Account IDs from seeded COA +let cashOnHandId: string; // 1010 — ASSET, DEBIT normal balance +let cashInBankId: string; // 1020 — ASSET, DEBIT normal balance +let arId: string; // 1100 — ASSET, DEBIT normal balance +let subCreditsId: string; // 1150 — ASSET, CREDIT normal balance (contra-asset) +let subRevenueId: string; // 4010 — REVENUE, CREDIT normal balance + +// A user to act as maker/checker +let adminUserId: string; + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create a test tenant + const tenant = await prisma.tenant.create({ + data: { + name: `JE Test Tenant ${TEST_TIMESTAMP}`, + slug: `je-test-${TEST_TIMESTAMP}`, + ownerEmail: `je-owner-${TEST_TIMESTAMP}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + tenantPrisma = withTenantContext(tenantId); + + // Seed Chart of Accounts + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // Look up account IDs we'll use in tests + const accounts = await prisma.account.findMany({ + where: { tenantId, code: { in: ["1010", "1020", "1100", "1150", "4010"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + cashOnHandId = accountMap.get("1010")!; + cashInBankId = accountMap.get("1020")!; + arId = accountMap.get("1100")!; + subCreditsId = accountMap.get("1150")!; + subRevenueId = accountMap.get("4010")!; + + // Create an admin user for maker-checker tests + const adminUser = await prisma.user.create({ + data: { + email: `je-admin-${TEST_TIMESTAMP}@test.example`, + passwordHash: "hashed", + firstName: "JE", + lastName: "Admin", + tenantId, + roles: ["ADMIN"], + isActive: true, + }, + }); + adminUserId = adminUser.id; +}); + +afterAll(async () => { + if (tenantId) { + // Clean up in reverse FK dependency order: + // 1. Delete all journal entry lines (references journalEntry and account) + await prisma.journalEntryLine.deleteMany({ where: { tenantId } }).catch(() => {}); + // 2. Null out self-referential reversesEntryId to break the FK cycle before deletion + await prisma.journalEntry.updateMany({ + where: { tenantId }, + data: { reversesEntryId: null }, + }).catch(() => {}); + // 3. Delete all journal entries + await prisma.journalEntry.deleteMany({ where: { tenantId } }).catch(() => {}); + // 4. Delete tenant (cascades to users, accounts, accountingPeriods) + await prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {}); + } + await prisma.$disconnect(); +}); + +// =========================================================================== +// CORE ENFORCEMENT: DEBIT = CREDIT BALANCE +// =========================================================================== + +describe("Balance enforcement", () => { + it("creates a balanced entry (debit = credit) successfully", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-15"), + description: "Test: cash to bank transfer", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 100, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 100 }, + ], + }); + + expect(entry).toBeDefined(); + expect(entry.lines).toHaveLength(2); + expect(entry.status).toBe(JournalEntryStatus.POSTED); + }); + + it("rejects an unbalanced entry (debit != credit)", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-16"), + description: "Test: unbalanced", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 100, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 50 }, // 100 debit, 50 credit — UNBALANCED + ], + }) + ).rejects.toThrow(/unbalanced/i); + }); + + it("rejects an entry with fewer than 2 lines", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-17"), + description: "Test: single line", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [{ accountId: cashOnHandId, debit: 100, credit: 0 }], + }) + ).rejects.toThrow(/at least 2 lines/i); + }); + + it("rejects a line with both debit and credit non-zero", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-18"), + description: "Test: both debit and credit", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 100, credit: 50 }, // INVALID: both non-zero + { accountId: cashOnHandId, debit: 0, credit: 50 }, + ], + }) + ).rejects.toThrow(/cannot have both debit and credit/i); + }); + + it("rejects a line with both debit and credit as zero", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-19"), + description: "Test: zero line", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 0, credit: 0 }, // INVALID: both zero + { accountId: cashOnHandId, debit: 0, credit: 0 }, + ], + }) + ).rejects.toThrow(/must have either a debit or credit/i); + }); + + it("handles decimal amounts correctly (e.g., 100.50)", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-01-20"), + description: "Test: decimal amounts", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: arId, debit: "100.50", credit: 0 }, + { accountId: subRevenueId, debit: 0, credit: "100.50" }, + ], + }); + + // Prisma Decimal.toString() may omit trailing zeros; use toNumber() for comparison + expect(entry.lines[0].debit.toNumber()).toBe(100.50); + expect(entry.lines[1].credit.toNumber()).toBe(100.50); + }); +}); + +// =========================================================================== +// CLOSED PERIOD PROTECTION +// =========================================================================== + +describe("Closed period protection", () => { + const CLOSED_YEAR = 2025; + const CLOSED_MONTH = 1; // January 2025 + + beforeAll(async () => { + // Create and then close January 2025 for this tenant + const period = await getOpenPeriod(prisma, tenantId, CLOSED_YEAR, CLOSED_MONTH); + await closePeriod(prisma, period.id, adminUserId); + }); + + it("blocks posting to a closed period", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2025-01-15"), // January 2025 is CLOSED + description: "Test: entry in closed period", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 100, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 100 }, + ], + }) + ).rejects.toThrow(/closed/i); + }); + + it("allows posting to an open period", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-02-15"), // February 2026 is OPEN + description: "Test: entry in open period", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 200, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 200 }, + ], + }); + + expect(entry).toBeDefined(); + expect(entry.status).toBe(JournalEntryStatus.POSTED); + }); +}); + +// =========================================================================== +// IMMUTABILITY +// =========================================================================== + +describe("Immutability", () => { + it("JournalEntryService has no update method", () => { + // Verify at the runtime level that no update method exists on the service + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(typeof (JournalEntryService as any).updateEntry).toBe("undefined"); + }); + + it("JournalEntryService has no delete method", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(typeof (JournalEntryService as any).deleteEntry).toBe("undefined"); + }); +}); + +// =========================================================================== +// REVERSING ENTRIES +// =========================================================================== + +describe("Reversing entries", () => { + let originalEntryId: string; + let originalEntryNumber: string; + + beforeAll(async () => { + // Create an entry to reverse + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-03-01"), + description: "Original entry for reversal test", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: arId, debit: 500, credit: 0 }, + { accountId: subRevenueId, debit: 0, credit: 500 }, + ], + }); + originalEntryId = entry.id; + originalEntryNumber = entry.entryNumber; + }); + + it("creates a reversing entry with swapped debits and credits", async () => { + const reversingEntry = await JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId, + entryId: originalEntryId, + reversedById: adminUserId, + date: new Date("2026-03-02"), + }); + + expect(reversingEntry).toBeDefined(); + expect(reversingEntry.status).toBe(JournalEntryStatus.POSTED); + expect(reversingEntry.source).toBe(JournalEntrySource.SYSTEM); + + // Lines should be swapped: AR was debit 500, now should be credit 500 + const arLine = reversingEntry.lines.find( + (l: { accountId: string }) => l.accountId === arId + ); + const revLine = reversingEntry.lines.find( + (l: { accountId: string }) => l.accountId === subRevenueId + ); + // Use toNumber() for comparison — Prisma Decimal.toString() may omit trailing zeros + expect(arLine?.credit.toNumber()).toBe(500); + expect(arLine?.debit.toNumber()).toBe(0); + expect(revLine?.debit.toNumber()).toBe(500); + expect(revLine?.credit.toNumber()).toBe(0); + }); + + it("marks the original entry as REVERSED", async () => { + const original = await tenantPrisma.journalEntry.findFirst({ + where: { id: originalEntryId }, + }); + expect(original?.status).toBe(JournalEntryStatus.REVERSED); + }); + + it("reversing entry references original via reversesEntryId", async () => { + const reversingEntry = await tenantPrisma.journalEntry.findFirst({ + where: { reversesEntryId: originalEntryId }, + }); + expect(reversingEntry).not.toBeNull(); + expect(reversingEntry?.reversesEntryId).toBe(originalEntryId); + }); + + it("cannot reverse an already-reversed entry", async () => { + await expect( + JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId, + entryId: originalEntryId, + reversedById: adminUserId, + }) + ).rejects.toThrow(/already been reversed/i); + }); + + it("reverse with custom description and date", async () => { + // Create another entry to reverse + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-03-05"), + description: "Entry to reverse with custom description", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashOnHandId, debit: 300, credit: 0 }, + { accountId: cashInBankId, debit: 0, credit: 300 }, + ], + }); + + const reversal = await JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId, + entryId: entry.id, + reversedById: adminUserId, + date: new Date("2026-03-10"), + description: "Custom reversal description", + }); + + expect(reversal.description).toBe("Custom reversal description"); + expect(reversal.date.toISOString().slice(0, 10)).toBe("2026-03-10"); + // Entry number should be for 2026 + expect(reversal.entryNumber).toMatch(/^JE-2026-/); + }); + + it("reversal description defaults to Reversal of {entryNumber}: {description}", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-03-06"), + description: "Default reversal desc test", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashOnHandId, debit: 150, credit: 0 }, + { accountId: cashInBankId, debit: 0, credit: 150 }, + ], + }); + + const reversal = await JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId, + entryId: entry.id, + reversedById: adminUserId, + }); + + expect(reversal.description).toContain(entry.entryNumber); + expect(reversal.description).toContain("Default reversal desc test"); + }); +}); + +// =========================================================================== +// ACCOUNT BALANCE DERIVATION +// =========================================================================== + +describe("Account balance derivation", () => { + // Use a unique far-future year to isolate balance tests from all others + // No other test in this file uses year 2097, so balances are precise + const BALANCE_YEAR = 2097; + + beforeAll(async () => { + // Entry 1: AR debit 1000, Revenue credit 1000 (April 1) + await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${BALANCE_YEAR}-04-01`), + description: "Balance test: invoice", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: arId, debit: 1000, credit: 0, description: "AR debit" }, + { accountId: subRevenueId, debit: 0, credit: 1000, description: "Revenue credit" }, + ], + }); + + // Entry 2: AR debit 500, Revenue credit 500 (April 5) + await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${BALANCE_YEAR}-04-05`), + description: "Balance test: second invoice", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: arId, debit: 500, credit: 0 }, + { accountId: subRevenueId, debit: 0, credit: 500 }, + ], + }); + + // Entry 3: Cash collected — AR credit 800, Cash debit 800 (April 10) + await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${BALANCE_YEAR}-04-10`), + description: "Balance test: payment received", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashOnHandId, debit: 800, credit: 0 }, + { accountId: arId, debit: 0, credit: 800 }, + ], + }); + }); + + it("computes account balance from POSTED journal entry lines (not stored)", async () => { + // AR balance for BALANCE_YEAR only: 1000 + 500 - 800 = 700 + // Use startDate + asOfDate to scope to BALANCE_YEAR only + const { balance } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: arId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-12-31`), + }); + expect(balance.toNumber()).toBe(700); + }); + + it("balance respects DEBIT normal balance (ASSET account)", async () => { + // Cash on Hand: DEBIT normal balance — balance = sum(debit) - sum(credit) + // Only entry 3 touches Cash on Hand in BALANCE_YEAR: debit 800 + const { balance } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: cashOnHandId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-12-31`), + }); + expect(balance.toNumber()).toBe(800); + }); + + it("balance respects CREDIT normal balance (REVENUE account)", async () => { + // Subscription Revenue: CREDIT normal balance — balance = sum(credit) - sum(debit) + // Revenue credited 1000 + 500 = 1500 in BALANCE_YEAR + const { balance } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: subRevenueId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-12-31`), + }); + expect(balance.toNumber()).toBe(1500); + }); + + it("balance with asOfDate filters correctly (excludes future entries)", async () => { + // All queries scoped to BALANCE_YEAR only (using startDate to exclude prior entries) + // As of April 3: only the April 1 entry included → AR = 1000 + const { balance: balanceApril3 } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: arId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-04-03`), + }); + + // As of April 6: April 1 + April 5 entries included → AR = 1000 + 500 = 1500 + const { balance: balanceApril6 } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: arId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-04-06`), + }); + + // As of April 30: all three entries included → AR = 1000 + 500 - 800 = 700 + const { balance: balanceApril30 } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: arId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-04-30`), + }); + + expect(balanceApril3.toNumber()).toBe(1000); + expect(balanceApril6.toNumber()).toBe(1500); + expect(balanceApril30.toNumber()).toBe(700); + }); + + it("balance returns 0 for account with no entries in the isolated period", async () => { + // Cash in Bank had no entries in BALANCE_YEAR + const { balance } = await JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: cashInBankId, + startDate: new Date(`${BALANCE_YEAR}-01-01`), + asOfDate: new Date(`${BALANCE_YEAR}-12-31`), + }); + expect(balance.toNumber()).toBe(0); + }); + + it("throws for non-existent accountId", async () => { + await expect( + JournalEntryService.getAccountBalance({ + tenantPrisma, + accountId: "non-existent-account-id", + }) + ).rejects.toThrow(/not found/i); + }); +}); + +// =========================================================================== +// TRIAL BALANCE +// =========================================================================== + +describe("Trial balance", () => { + it("returns trial balance with total debits equal to total credits", async () => { + const trialBalance = await JournalEntryService.getTrialBalance({ tenantPrisma }); + + const totalDebits = trialBalance.reduce( + (sum, line) => sum + line.debitBalance.toNumber(), + 0 + ); + const totalCredits = trialBalance.reduce( + (sum, line) => sum + line.creditBalance.toNumber(), + 0 + ); + + // Round to 2 decimal places to avoid float comparison issues + expect(Math.round(totalDebits * 100)).toBe(Math.round(totalCredits * 100)); + }); + + it("includes all accounts in the COA (even those with zero balance)", async () => { + const trialBalance = await JournalEntryService.getTrialBalance({ tenantPrisma }); + // The ISP COA has 28 accounts; trial balance should include all + expect(trialBalance.length).toBeGreaterThanOrEqual(20); + }); + + it("trial balance contains account codes and names", async () => { + const trialBalance = await JournalEntryService.getTrialBalance({ tenantPrisma }); + const arLine = trialBalance.find((l) => l.accountCode === "1100"); + expect(arLine).toBeDefined(); + expect(arLine?.accountName).toBe("Accounts Receivable"); + }); + + it("trial balance with asOfDate filters correctly", async () => { + // Trial balance in the far past should show zero balances (no entries before 2000) + const trialBalance2000 = await JournalEntryService.getTrialBalance({ + tenantPrisma, + asOfDate: new Date("2000-01-01"), + }); + const total = trialBalance2000.reduce( + (sum, l) => sum + l.debitBalance.toNumber() + l.creditBalance.toNumber(), + 0 + ); + expect(total).toBe(0); + }); +}); + +// =========================================================================== +// MAKER-CHECKER WORKFLOW +// =========================================================================== + +describe("Maker-checker workflow", () => { + it("MANUAL source creates entry with DRAFT status", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-05-01"), + description: "Manual: cash on hand to bank", + source: JournalEntrySource.MANUAL, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 250, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 250 }, + ], + }); + + expect(entry.status).toBe(JournalEntryStatus.DRAFT); + expect(entry.source).toBe(JournalEntrySource.MANUAL); + expect(entry.approvedById).toBeNull(); + }); + + it("SYSTEM source creates entry with POSTED status", async () => { + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-05-02"), + description: "System: auto-generated entry", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 100, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 100 }, + ], + }); + + expect(entry.status).toBe(JournalEntryStatus.POSTED); + expect(entry.source).toBe(JournalEntrySource.SYSTEM); + }); + + it("approve changes DRAFT to POSTED and sets approvedById and approvedAt", async () => { + const draft = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-05-03"), + description: "Manual entry for approval test", + source: JournalEntrySource.MANUAL, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 400, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 400 }, + ], + }); + + expect(draft.status).toBe(JournalEntryStatus.DRAFT); + + const approved = await JournalEntryService.approveEntry({ + tenantPrisma, + entryId: draft.id, + approvedById: adminUserId, + }); + + expect(approved.status).toBe(JournalEntryStatus.POSTED); + expect(approved.approvedById).toBe(adminUserId); + expect(approved.approvedAt).not.toBeNull(); + }); + + it("self-approve is allowed (same user creates and approves)", async () => { + const draft = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-05-04"), + description: "Self-approve test", + source: JournalEntrySource.MANUAL, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 75, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 75 }, + ], + }); + + // Same user approves their own entry — should succeed + const approved = await JournalEntryService.approveEntry({ + tenantPrisma, + entryId: draft.id, + approvedById: adminUserId, // same as createdById + }); + + expect(approved.status).toBe(JournalEntryStatus.POSTED); + expect(approved.createdById).toBe(approved.approvedById); + }); + + it("cannot approve an already-POSTED entry", async () => { + // SYSTEM entry is already POSTED + const posted = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-05-05"), + description: "Already posted", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 50, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 50 }, + ], + }); + + await expect( + JournalEntryService.approveEntry({ + tenantPrisma, + entryId: posted.id, + approvedById: adminUserId, + }) + ).rejects.toThrow(/cannot approve/i); + }); + + it("cannot approve a non-existent entry", async () => { + await expect( + JournalEntryService.approveEntry({ + tenantPrisma, + entryId: "non-existent-id", + approvedById: adminUserId, + }) + ).rejects.toThrow(/not found/i); + }); +}); + +// =========================================================================== +// ENTRY NUMBERING +// =========================================================================== + +describe("Entry numbering", () => { + it("first entry of year gets JE-{YYYY}-0001 format", async () => { + // Use a unique future year to avoid interference with other tests + const UNIQUE_YEAR = 2099; + + const entry = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${UNIQUE_YEAR}-01-01`), + description: "First entry of year test", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 10, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 10 }, + ], + }); + + expect(entry.entryNumber).toBe(`JE-${UNIQUE_YEAR}-0001`); + }); + + it("subsequent entries for the same year increment correctly", async () => { + // Use 2098 year to avoid interference + const UNIQUE_YEAR = 2098; + + const entry1 = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${UNIQUE_YEAR}-01-01`), + description: "First entry", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 10, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 10 }, + ], + }); + + const entry2 = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(`${UNIQUE_YEAR}-02-01`), + description: "Second entry", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: cashInBankId, debit: 20, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 20 }, + ], + }); + + expect(entry1.entryNumber).toBe(`JE-${UNIQUE_YEAR}-0001`); + expect(entry2.entryNumber).toBe(`JE-${UNIQUE_YEAR}-0002`); + }); + + it("entry numbers are unique per tenant per year", async () => { + // All 2026 entries should have unique numbers + const entries = await tenantPrisma.journalEntry.findMany({ + where: { entryNumber: { startsWith: "JE-2026-" } }, + select: { entryNumber: true }, + }); + + const numbers = entries.map((e: { entryNumber: string }) => e.entryNumber); + const uniqueNumbers = new Set(numbers); + expect(uniqueNumbers.size).toBe(numbers.length); + }); +}); + +// =========================================================================== +// ACCOUNT VALIDATION +// =========================================================================== + +describe("Account validation", () => { + it("rejects entry with non-existent accountId", async () => { + await expect( + JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date("2026-06-01"), + description: "Invalid account test", + source: JournalEntrySource.SYSTEM, + createdById: adminUserId, + lines: [ + { accountId: "non-existent-account-id", debit: 100, credit: 0 }, + { accountId: cashOnHandId, debit: 0, credit: 100 }, + ], + }) + ).rejects.toThrow(/not found|do not belong/i); + }); +}); diff --git a/src/lib/accounting/journal-entry-service.ts b/src/lib/accounting/journal-entry-service.ts index 1322fd5..233e0df 100644 --- a/src/lib/accounting/journal-entry-service.ts +++ b/src/lib/accounting/journal-entry-service.ts @@ -81,7 +81,10 @@ export interface ReverseEntryInput { export interface GetAccountBalanceInput { tenantPrisma: TenantPrismaClient; accountId: string; + /** Include only entries on or before this date (inclusive) */ asOfDate?: Date; + /** Include only entries on or after this date (inclusive) */ + startDate?: Date; } export interface AccountBalanceResult { @@ -442,7 +445,7 @@ export class JournalEntryService { * @param asOfDate - If provided, only includes entries dated on or before this date */ static async getAccountBalance(input: GetAccountBalanceInput): Promise { - const { tenantPrisma, accountId, asOfDate } = input; + const { tenantPrisma, accountId, asOfDate, startDate } = input; // Fetch account to determine normal balance direction const account = await tenantPrisma.account.findFirst({ @@ -454,8 +457,11 @@ export class JournalEntryService { throw new Error(`Account not found: ${accountId}`); } - // Build date filter for asOfDate - const dateFilter = asOfDate ? { date: { lte: asOfDate } } : {}; + // Build date filter for date range + const dateConditions: Record = {}; + if (asOfDate) dateConditions.lte = asOfDate; + if (startDate) dateConditions.gte = startDate; + const dateFilter = Object.keys(dateConditions).length > 0 ? { date: dateConditions } : {}; // Aggregate debit and credit sums from POSTED journal entry lines const result = await tenantPrisma.journalEntryLine.aggregate({