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:
62
src/app/api/accounting/accounts/[id]/balance/route.ts
Normal file
62
src/app/api/accounting/accounts/[id]/balance/route.ts
Normal 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);
|
||||
}
|
||||
49
src/app/api/accounting/journal-entries/[id]/approve/route.ts
Normal file
49
src/app/api/accounting/journal-entries/[id]/approve/route.ts
Normal 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);
|
||||
}
|
||||
68
src/app/api/accounting/journal-entries/[id]/reverse/route.ts
Normal file
68
src/app/api/accounting/journal-entries/[id]/reverse/route.ts
Normal 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);
|
||||
}
|
||||
48
src/app/api/accounting/journal-entries/[id]/route.ts
Normal file
48
src/app/api/accounting/journal-entries/[id]/route.ts
Normal 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);
|
||||
}
|
||||
149
src/app/api/accounting/journal-entries/route.ts
Normal file
149
src/app/api/accounting/journal-entries/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
855
src/lib/__tests__/journal-entry-service.test.ts
Normal file
855
src/lib/__tests__/journal-entry-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user