feat(04-05): financial report API routes — trial balance, income statement, balance sheet, drill-down

- GET /api/reports/trial-balance with optional asOfDate filter
- GET /api/reports/income-statement with required startDate/endDate
- GET /api/reports/balance-sheet with required asOfDate
- GET /api/reports/accounts/[id]/entries drill-down with optional date range
- All routes use withPermission("read", "Report") for ADMIN access
- FinancialReportService already existed with all 4 methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 12:31:33 +08:00
parent cc8314f854
commit 5366e8c1ad
4 changed files with 211 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
/**
* GET /api/reports/accounts/[id]/entries
*
* Drill-down: all POSTED journal entry lines for a specific account.
* Shows entry details with running balance.
*
* Query params:
* startDate? - ISO date string
* endDate? - ISO date string
*
* Access: ADMIN, OFFICE_STAFF.
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { FinancialReportService } from "@/lib/services/financial-report-service";
export function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return withPermission("read", "Report")(
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: accountId } = await params;
const { searchParams } = new URL(innerReq.url);
const startDateParam = searchParams.get("startDate");
const endDateParam = searchParams.get("endDate");
const tenantPrisma = withTenantContext(user.tenantId);
try {
const report = await FinancialReportService.getAccountEntries(tenantPrisma, {
accountId,
startDate: startDateParam ? new Date(startDateParam) : undefined,
endDate: endDateParam ? new Date(endDateParam) : undefined,
});
return NextResponse.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get account entries";
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,57 @@
/**
* GET /api/reports/balance-sheet
*
* Balance Sheet -- assets = liabilities + equity as of a date.
* Derived entirely from POSTED journal entry lines.
*
* Query params:
* asOfDate - ISO date string (required)
*
* Access: ADMIN only.
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { FinancialReportService } from "@/lib/services/financial-report-service";
export const GET = withPermission("read", "Report")(
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 asOfDateParam = searchParams.get("asOfDate");
if (!asOfDateParam) {
return NextResponse.json(
{ error: "asOfDate query parameter is required" },
{ status: 400 }
);
}
const asOfDate = new Date(asOfDateParam);
if (isNaN(asOfDate.getTime())) {
return NextResponse.json(
{ error: "asOfDate must be a valid ISO date string" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const report = await FinancialReportService.getBalanceSheet(tenantPrisma, {
asOfDate,
});
return NextResponse.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to generate balance sheet";
return NextResponse.json({ error: message }, { status: 500 });
}
}
);

View File

@@ -0,0 +1,61 @@
/**
* GET /api/reports/income-statement
*
* Income Statement -- revenue minus expenses for a date range.
* Derived entirely from POSTED journal entry lines.
*
* Query params:
* startDate - ISO date string (required)
* endDate - ISO date string (required)
*
* Access: ADMIN only.
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { FinancialReportService } from "@/lib/services/financial-report-service";
export const GET = withPermission("read", "Report")(
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 startDateParam = searchParams.get("startDate");
const endDateParam = searchParams.get("endDate");
if (!startDateParam || !endDateParam) {
return NextResponse.json(
{ error: "startDate and endDate query parameters are required" },
{ status: 400 }
);
}
const startDate = new Date(startDateParam);
const endDate = new Date(endDateParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return NextResponse.json(
{ error: "startDate and endDate must be valid ISO date strings" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const report = await FinancialReportService.getIncomeStatement(tenantPrisma, {
startDate,
endDate,
});
return NextResponse.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to generate income statement";
return NextResponse.json({ error: message }, { status: 500 });
}
}
);

View File

@@ -0,0 +1,41 @@
/**
* GET /api/reports/trial-balance
*
* Trial Balance report -- all account balances, verifies debits = credits.
* Derived entirely from POSTED journal entry lines.
*
* Query params:
* asOfDate? - ISO date string (default: now)
*
* Access: ADMIN only.
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { FinancialReportService } from "@/lib/services/financial-report-service";
export const GET = withPermission("read", "Report")(
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 asOfDateParam = searchParams.get("asOfDate");
const tenantPrisma = withTenantContext(user.tenantId);
try {
const report = await FinancialReportService.getTrialBalance(tenantPrisma, {
asOfDate: asOfDateParam ? new Date(asOfDateParam) : undefined,
});
return NextResponse.json(report);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to generate trial balance";
return NextResponse.json({ error: message }, { status: 500 });
}
}
);