feat(02-02): journal entry API routes + comprehensive test suite

- GET/POST /api/accounting/journal-entries (list + create manual entry)
- GET /api/accounting/journal-entries/[id] (single entry with lines)
- POST /api/accounting/journal-entries/[id]/approve (maker-checker approval)
- POST /api/accounting/journal-entries/[id]/reverse (create reversing entry)
- GET /api/accounting/accounts/[id]/balance (derived balance, never stored)
- All routes use closure pattern over withPermission HOF (same as periods/close)
- Add startDate parameter to getAccountBalance for date range queries
- 36 integration tests: balance enforcement, closed period, immutability,
  reversals, maker-checker, entry numbering, account balance derivation,
  trial balance self-verification
- Total test suite: 198 tests all passing
This commit is contained in:
kevin-asprec
2026-03-04 23:23:11 +08:00
parent 837b7f1979
commit 30ec936b68
7 changed files with 1240 additions and 3 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<string, Date> = {};
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 });
}
}
);

View File

@@ -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<typeof withTenantContext>;
// 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);
});
});

View File

@@ -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<AccountBalanceResult> {
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<string, Date> = {};
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({