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

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

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
/**
* GET /api/accounting/accounts/[id]/balance
*
* Returns the derived balance for an account, computed by summing POSTED journal entry lines.
* Balances are NEVER stored — always derived on demand.
*
* Query params:
* ?asOfDate=ISO8601 — compute balance as of this date (defaults to all-time)
* ?startDate=ISO8601 — include entries on or after this date (defaults to all-time)
*
* Requires: read on Account (ADMIN or OFFICE_STAFF).
*
* Response:
* 200 OK — { accountId, balance, asOfDate }
* 400 Bad Request — no tenant context
* 404 Not Found — account not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Account")(
async (innerReq: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const { searchParams } = new URL(innerReq.url);
const asOfDateParam = searchParams.get("asOfDate");
const startDateParam = searchParams.get("startDate");
const tenantPrisma = withTenantContext(user.tenantId);
try {
const result = await JournalEntryService.getAccountBalance({
tenantPrisma,
accountId: id,
asOfDate: asOfDateParam ? new Date(asOfDateParam) : undefined,
startDate: startDateParam ? new Date(startDateParam) : undefined,
});
return NextResponse.json({
accountId: result.accountId,
balance: result.balance.toFixed(2),
asOfDate: result.asOfDate ? result.asOfDate.toISOString() : null,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get account balance";
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
)(req);
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
/**
* POST /api/accounting/journal-entries/[id]/approve
*
* Approves a manual journal entry (DRAFT -> POSTED).
* Implements maker-checker workflow. Self-approval is allowed for single-person operations.
*
* Requires: manage on Account (ADMIN only).
*
* Response:
* 200 OK — updated journal entry with lines
* 400 Bad Request — entry already posted or invalid state
* 404 Not Found — entry not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("manage", "Account")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const entry = await JournalEntryService.approveEntry({
tenantPrisma,
entryId: id,
approvedById: user.id,
});
return NextResponse.json(entry);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve journal entry";
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
)(req);
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
/**
* POST /api/accounting/journal-entries/[id]/reverse
*
* Reverses a posted journal entry by creating a new entry with swapped debits/credits.
* The original entry is marked REVERSED.
*
* Body (optional):
* {
* date?: ISO8601 string — accounting date for the reversing entry (defaults to today)
* description?: string — description for the reversing entry
* }
*
* Requires: manage on Account (ADMIN only).
*
* Response:
* 201 Created — the new reversing entry with lines
* 400 Bad Request — entry already reversed or invalid
* 404 Not Found — entry not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("manage", "Account")(
async (innerReq: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
let body: { date?: string; description?: string } = {};
try {
const text = await innerReq.text();
if (text.trim()) {
body = JSON.parse(text);
}
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
try {
const reversingEntry = await JournalEntryService.reverseEntry({
tenantPrisma,
tenantId: user.tenantId,
entryId: id,
reversedById: user.id,
date: body.date ? new Date(body.date) : undefined,
description: body.description,
});
return NextResponse.json(reversingEntry, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to reverse journal entry";
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
)(req);
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* GET /api/accounting/journal-entries/[id]
*
* Returns a single journal entry with its lines.
*
* Requires: read on Account (ADMIN or OFFICE_STAFF).
*
* Response:
* 200 OK — journal entry with lines
* 400 Bad Request — no tenant context
* 404 Not Found — entry not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Account")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
const entry = await tenantPrisma.journalEntry.findFirst({
where: { id },
include: {
lines: {
orderBy: { createdAt: "asc" },
},
},
});
if (!entry) {
return NextResponse.json({ error: "Journal entry not found" }, { status: 404 });
}
return NextResponse.json(entry);
}
)(req);
}

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
import { JournalEntrySource, JournalEntryStatus } from "@prisma/client";
/**
* GET /api/accounting/journal-entries
*
* Lists journal entries for the authenticated tenant.
* Ordered by date descending (most recent first).
*
* Query params:
* ?startDate=ISO8601 — filter entries on or after this date
* ?endDate=ISO8601 — filter entries on or before this date
* ?status=DRAFT|POSTED|REVERSED|... — filter by status
* ?source=SYSTEM|MANUAL — filter by source
*
* Requires: read on Account (ADMIN or OFFICE_STAFF).
*
* Response:
* 200 OK — Array of journal entries with lines
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const GET = withPermission("read", "Account")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { searchParams } = new URL(req.url);
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
const status = searchParams.get("status") as JournalEntryStatus | null;
const source = searchParams.get("source") as JournalEntrySource | null;
// Validate enum values
if (status && !Object.values(JournalEntryStatus).includes(status)) {
return NextResponse.json({ error: `Invalid status: ${status}` }, { status: 400 });
}
if (source && !Object.values(JournalEntrySource).includes(source)) {
return NextResponse.json({ error: `Invalid source: ${source}` }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
// Build date filter
const dateFilter: Record<string, Date> = {};
if (startDate) dateFilter.gte = new Date(startDate);
if (endDate) dateFilter.lte = new Date(endDate);
const entries = await tenantPrisma.journalEntry.findMany({
where: {
...(Object.keys(dateFilter).length > 0 ? { date: dateFilter } : {}),
...(status ? { status } : {}),
...(source ? { source } : {}),
},
include: {
lines: {
orderBy: { createdAt: "asc" },
},
},
orderBy: { date: "desc" },
});
return NextResponse.json(entries);
}
);
/**
* POST /api/accounting/journal-entries
*
* Creates a manual journal entry. Requires ADMIN role.
* Manual entries start with status DRAFT (maker-checker workflow).
*
* Body:
* {
* date: ISO8601 string,
* description: string,
* lines: [{ accountId: string, debit: number, credit: number, description?: string }]
* }
*
* Requires: manage on Account (ADMIN only).
*
* Response:
* 201 Created — journal entry with lines
* 400 Bad Request — unbalanced entry, missing fields, invalid accountId
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const POST = withPermission("manage", "Account")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: {
date?: string;
description?: string;
lines?: Array<{
accountId: string;
debit: number | string;
credit: number | string;
description?: string;
}>;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { date, description, lines } = body;
if (!date || !description || !lines || !Array.isArray(lines)) {
return NextResponse.json(
{ error: "Missing required fields: date, description, lines" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const entry = await JournalEntryService.createEntry({
tenantPrisma,
tenantId: user.tenantId,
date: new Date(date),
description,
lines,
source: JournalEntrySource.MANUAL,
createdById: user.id,
});
return NextResponse.json(entry, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create journal entry";
return NextResponse.json({ error: message }, { status: 400 });
}
}
);