feat(02-01): COA auto-provisioning on tenant signup + API routes + tests

- Create seed-coa.ts: seedChartOfAccounts(tx, tenantId) seeds 28 accounts in transaction
- Update tenant.ts: createTenant() calls seedChartOfAccounts inside $transaction block
- Add GET /api/accounting/accounts — list COA for tenant (requires read:Account)
- Add GET /api/accounting/periods — list accounting periods (requires read:Account)
- Add POST /api/accounting/periods/[id]/close — close period (requires manage:Account)
- Add 28 integration tests: COA definition, seeding, period management, createTenant integration
- All 121 tests pass (93 existing + 28 new)
This commit is contained in:
kevin-asprec
2026-03-04 22:51:52 +08:00
parent 7c0caf5244
commit a53ee9cd1c
6 changed files with 632 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* GET /api/accounting/accounts
*
* Returns the full Chart of Accounts for the authenticated tenant.
* Accounts are ordered by code ascending (1000, 1010, 1020, ... 5090).
*
* Requires: ADMIN role (read on Account subject).
*
* Response:
* 200 OK — Array of { id, code, name, accountType, normalBalance, parentId, isSystemAccount }
* 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 tenantPrisma = withTenantContext(user.tenantId);
const accounts = await tenantPrisma.account.findMany({
orderBy: { code: "asc" },
select: {
id: true,
code: true,
name: true,
accountType: true,
normalBalance: true,
parentId: true,
isSystemAccount: true,
createdAt: true,
},
});
return NextResponse.json(accounts);
}
);

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { closePeriod } from "@/lib/accounting/accounting-period";
import { prisma } from "@/lib/prisma";
/**
* POST /api/accounting/periods/[id]/close
*
* Closes an accounting period, preventing future journal entries from
* being posted to that period. The period must currently be OPEN.
*
* Requires: ADMIN role (manage on Account subject).
*
* Path param:
* id — The UUID of the AccountingPeriod to close
*
* Response:
* 200 OK — { id, year, month, status: "CLOSED", closedAt, closedById }
* 400 Bad Request — period is already closed
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
* 404 Not Found — period not found or belongs to different tenant
* 500 Internal — unexpected error
*/
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("manage", "Account")(
async (_req: NextRequest, { user }) => {
const { id: periodId } = await params;
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
// Verify the period exists and belongs to this tenant before closing
const period = await prisma.accountingPeriod.findFirst({
where: { id: periodId, tenantId: user.tenantId },
});
if (!period) {
return NextResponse.json(
{ error: "Accounting period not found" },
{ status: 404 }
);
}
try {
const closed = await closePeriod(prisma, periodId, user.id);
return NextResponse.json({
id: closed.id,
year: closed.year,
month: closed.month,
status: closed.status,
closedAt: closed.closedAt,
closedById: closed.closedById,
tenantId: closed.tenantId,
});
} catch (error) {
if (error instanceof Error && error.message.includes("already closed")) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("[POST /api/accounting/periods/[id]/close] Unexpected error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred. Please try again." },
{ status: 500 }
);
}
}
)(req);
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* GET /api/accounting/periods
*
* Lists all accounting periods for the authenticated tenant.
* Ordered by year descending, then month descending (most recent first).
*
* Requires: ADMIN role (read on Account subject).
*
* Response:
* 200 OK — Array of { id, year, month, status, closedAt, closedById, createdAt }
* 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 tenantPrisma = withTenantContext(user.tenantId);
const periods = await tenantPrisma.accountingPeriod.findMany({
orderBy: [{ year: "desc" }, { month: "desc" }],
select: {
id: true,
year: true,
month: true,
status: true,
closedAt: true,
closedById: true,
createdAt: true,
},
});
return NextResponse.json(periods);
}
);