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 {
|
export interface GetAccountBalanceInput {
|
||||||
tenantPrisma: TenantPrismaClient;
|
tenantPrisma: TenantPrismaClient;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
/** Include only entries on or before this date (inclusive) */
|
||||||
asOfDate?: Date;
|
asOfDate?: Date;
|
||||||
|
/** Include only entries on or after this date (inclusive) */
|
||||||
|
startDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountBalanceResult {
|
export interface AccountBalanceResult {
|
||||||
@@ -442,7 +445,7 @@ export class JournalEntryService {
|
|||||||
* @param asOfDate - If provided, only includes entries dated on or before this date
|
* @param asOfDate - If provided, only includes entries dated on or before this date
|
||||||
*/
|
*/
|
||||||
static async getAccountBalance(input: GetAccountBalanceInput): Promise<AccountBalanceResult> {
|
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
|
// Fetch account to determine normal balance direction
|
||||||
const account = await tenantPrisma.account.findFirst({
|
const account = await tenantPrisma.account.findFirst({
|
||||||
@@ -454,8 +457,11 @@ export class JournalEntryService {
|
|||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build date filter for asOfDate
|
// Build date filter for date range
|
||||||
const dateFilter = asOfDate ? { date: { lte: asOfDate } } : {};
|
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
|
// Aggregate debit and credit sums from POSTED journal entry lines
|
||||||
const result = await tenantPrisma.journalEntryLine.aggregate({
|
const result = await tenantPrisma.journalEntryLine.aggregate({
|
||||||
|
|||||||
Reference in New Issue
Block a user