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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user