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:
45
src/app/api/accounting/accounts/route.ts
Normal file
45
src/app/api/accounting/accounts/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
74
src/app/api/accounting/periods/[id]/close/route.ts
Normal file
74
src/app/api/accounting/periods/[id]/close/route.ts
Normal 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);
|
||||||
|
}
|
||||||
44
src/app/api/accounting/periods/route.ts
Normal file
44
src/app/api/accounting/periods/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
400
src/lib/__tests__/accounting-coa.test.ts
Normal file
400
src/lib/__tests__/accounting-coa.test.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Accounting COA Integration Tests
|
||||||
|
*
|
||||||
|
* Tests the Chart of Accounts seeding, accounting period management,
|
||||||
|
* and the integration with createTenant().
|
||||||
|
*
|
||||||
|
* These tests require a live PostgreSQL database connection.
|
||||||
|
*
|
||||||
|
* WHAT IS TESTED:
|
||||||
|
* - ISP_CHART_OF_ACCOUNTS definition correctness (type coverage, normal balances)
|
||||||
|
* - seedChartOfAccounts creates the right number and structure of accounts
|
||||||
|
* - Parent-child relationships (parentId resolved correctly from parentCode)
|
||||||
|
* - closePeriod sets status to CLOSED with timestamp
|
||||||
|
* - closePeriod throws on already-closed period
|
||||||
|
* - isDateInClosedPeriod returns correct boolean
|
||||||
|
* - createTenant auto-provisions COA (integration: create tenant, verify accounts)
|
||||||
|
*
|
||||||
|
* ISOLATION STRATEGY:
|
||||||
|
* Each test creates data with unique IDs. afterAll() cleans up by deleting test tenants
|
||||||
|
* (cascade deletes users and accounts via FK constraints).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||||
|
import { ISP_CHART_OF_ACCOUNTS } from "@/lib/accounting/chart-of-accounts";
|
||||||
|
import {
|
||||||
|
closePeriod,
|
||||||
|
getOpenPeriod,
|
||||||
|
isDateInClosedPeriod,
|
||||||
|
} from "@/lib/accounting/accounting-period";
|
||||||
|
import { createTenant } from "@/lib/tenant";
|
||||||
|
import { TenantStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_TIMESTAMP = Date.now();
|
||||||
|
|
||||||
|
// Tenant created directly for seeding tests
|
||||||
|
let seedTestTenantId: string;
|
||||||
|
|
||||||
|
// Tenant created via createTenant() for integration test
|
||||||
|
let integrationTenantId: string;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup / Teardown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a tenant directly for unit-level seeding tests
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: `COA Test Tenant ${TEST_TIMESTAMP}`,
|
||||||
|
slug: `coa-test-${TEST_TIMESTAMP}`,
|
||||||
|
ownerEmail: `coa-owner-${TEST_TIMESTAMP}@test.example`,
|
||||||
|
status: TenantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
seedTestTenantId = tenant.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up all test tenants (cascade deletes users, accounts, periods)
|
||||||
|
if (seedTestTenantId) {
|
||||||
|
await prisma.tenant.delete({ where: { id: seedTestTenantId } }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (integrationTenantId) {
|
||||||
|
await prisma.tenant.delete({ where: { id: integrationTenantId } }).catch(() => {});
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// COA DEFINITION TESTS (pure — no DB needed)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("ISP_CHART_OF_ACCOUNTS definition", () => {
|
||||||
|
it("has 20 or more accounts defined", () => {
|
||||||
|
expect(ISP_CHART_OF_ACCOUNTS.length).toBeGreaterThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers all 5 account types", () => {
|
||||||
|
const types = new Set(ISP_CHART_OF_ACCOUNTS.map((a) => a.accountType));
|
||||||
|
expect(types.has("ASSET")).toBe(true);
|
||||||
|
expect(types.has("LIABILITY")).toBe(true);
|
||||||
|
expect(types.has("EQUITY")).toBe(true);
|
||||||
|
expect(types.has("REVENUE")).toBe(true);
|
||||||
|
expect(types.has("EXPENSE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assets have DEBIT normal balance", () => {
|
||||||
|
const assetAccounts = ISP_CHART_OF_ACCOUNTS.filter(
|
||||||
|
(a) => a.accountType === "ASSET" && a.code !== "1150"
|
||||||
|
);
|
||||||
|
for (const account of assetAccounts) {
|
||||||
|
expect(account.normalBalance).toBe("DEBIT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expenses have DEBIT normal balance", () => {
|
||||||
|
const expenseAccounts = ISP_CHART_OF_ACCOUNTS.filter(
|
||||||
|
(a) => a.accountType === "EXPENSE"
|
||||||
|
);
|
||||||
|
expect(expenseAccounts.length).toBeGreaterThan(0);
|
||||||
|
for (const account of expenseAccounts) {
|
||||||
|
expect(account.normalBalance).toBe("DEBIT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liabilities have CREDIT normal balance", () => {
|
||||||
|
const liabilityAccounts = ISP_CHART_OF_ACCOUNTS.filter(
|
||||||
|
(a) => a.accountType === "LIABILITY"
|
||||||
|
);
|
||||||
|
expect(liabilityAccounts.length).toBeGreaterThan(0);
|
||||||
|
for (const account of liabilityAccounts) {
|
||||||
|
expect(account.normalBalance).toBe("CREDIT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equity accounts have CREDIT normal balance", () => {
|
||||||
|
const equityAccounts = ISP_CHART_OF_ACCOUNTS.filter(
|
||||||
|
(a) => a.accountType === "EQUITY"
|
||||||
|
);
|
||||||
|
expect(equityAccounts.length).toBeGreaterThan(0);
|
||||||
|
for (const account of equityAccounts) {
|
||||||
|
expect(account.normalBalance).toBe("CREDIT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revenue accounts have CREDIT normal balance", () => {
|
||||||
|
const revenueAccounts = ISP_CHART_OF_ACCOUNTS.filter(
|
||||||
|
(a) => a.accountType === "REVENUE"
|
||||||
|
);
|
||||||
|
expect(revenueAccounts.length).toBeGreaterThan(0);
|
||||||
|
for (const account of revenueAccounts) {
|
||||||
|
expect(account.normalBalance).toBe("CREDIT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Subscriber Credits (1150) is a contra-asset with CREDIT normal balance", () => {
|
||||||
|
const subscriberCredits = ISP_CHART_OF_ACCOUNTS.find((a) => a.code === "1150");
|
||||||
|
expect(subscriberCredits).toBeDefined();
|
||||||
|
expect(subscriberCredits?.accountType).toBe("ASSET");
|
||||||
|
expect(subscriberCredits?.normalBalance).toBe("CREDIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all parent codes reference earlier entries in the array", () => {
|
||||||
|
const seenCodes = new Set<string>();
|
||||||
|
for (const account of ISP_CHART_OF_ACCOUNTS) {
|
||||||
|
if (account.parentCode) {
|
||||||
|
// Parent must have been defined before the child
|
||||||
|
expect(seenCodes.has(account.parentCode)).toBe(true);
|
||||||
|
}
|
||||||
|
seenCodes.add(account.code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// SEED COA INTEGRATION TESTS
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("seedChartOfAccounts (integration)", () => {
|
||||||
|
let seededCount: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Seed COA for the test tenant inside a transaction
|
||||||
|
seededCount = await prisma.$transaction(async (tx) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return seedChartOfAccounts(tx as any, seedTestTenantId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the correct count of accounts created", () => {
|
||||||
|
expect(seededCount).toBe(ISP_CHART_OF_ACCOUNTS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates account records in the database", async () => {
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: seedTestTenantId },
|
||||||
|
});
|
||||||
|
expect(accounts.length).toBe(ISP_CHART_OF_ACCOUNTS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all accounts have isSystemAccount = true", async () => {
|
||||||
|
const nonSystem = await prisma.account.findMany({
|
||||||
|
where: { tenantId: seedTestTenantId, isSystemAccount: false },
|
||||||
|
});
|
||||||
|
expect(nonSystem.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets parentId correctly for child accounts", async () => {
|
||||||
|
// 1010 (Cash on Hand) should have parentId = 1000's id
|
||||||
|
const parent1000 = await prisma.account.findFirst({
|
||||||
|
where: { tenantId: seedTestTenantId, code: "1000" },
|
||||||
|
});
|
||||||
|
const child1010 = await prisma.account.findFirst({
|
||||||
|
where: { tenantId: seedTestTenantId, code: "1010" },
|
||||||
|
});
|
||||||
|
expect(parent1000).not.toBeNull();
|
||||||
|
expect(child1010).not.toBeNull();
|
||||||
|
expect(child1010?.parentId).toBe(parent1000?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("top-level accounts (no parentCode) have null parentId", async () => {
|
||||||
|
// Top-level accounts: 1000, 2000, 3000, 4000, 5000
|
||||||
|
const topLevelCodes = ISP_CHART_OF_ACCOUNTS
|
||||||
|
.filter((a) => !a.parentCode)
|
||||||
|
.map((a) => a.code);
|
||||||
|
|
||||||
|
const topLevelAccounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: seedTestTenantId, code: { in: topLevelCodes } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const account of topLevelAccounts) {
|
||||||
|
expect(account.parentId).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("represents all 5 account types in the database", async () => {
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: seedTestTenantId },
|
||||||
|
select: { accountType: true },
|
||||||
|
});
|
||||||
|
const types = new Set(accounts.map((a) => a.accountType));
|
||||||
|
expect(types.has("ASSET")).toBe(true);
|
||||||
|
expect(types.has("LIABILITY")).toBe(true);
|
||||||
|
expect(types.has("EQUITY")).toBe(true);
|
||||||
|
expect(types.has("REVENUE")).toBe(true);
|
||||||
|
expect(types.has("EXPENSE")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// ACCOUNTING PERIOD TESTS
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("closePeriod and isDateInClosedPeriod (integration)", () => {
|
||||||
|
const PERIOD_YEAR = 2025;
|
||||||
|
const PERIOD_MONTH = 6; // June 2025
|
||||||
|
|
||||||
|
let periodId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a period to test with
|
||||||
|
const period = await getOpenPeriod(prisma, seedTestTenantId, PERIOD_YEAR, PERIOD_MONTH);
|
||||||
|
periodId = period.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOpenPeriod creates a new OPEN period if one does not exist", async () => {
|
||||||
|
const period = await prisma.accountingPeriod.findFirst({
|
||||||
|
where: { tenantId: seedTestTenantId, year: PERIOD_YEAR, month: PERIOD_MONTH },
|
||||||
|
});
|
||||||
|
expect(period).not.toBeNull();
|
||||||
|
expect(period?.status).toBe("OPEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOpenPeriod returns existing period if already OPEN (no duplicate)", async () => {
|
||||||
|
const period1 = await getOpenPeriod(prisma, seedTestTenantId, PERIOD_YEAR, PERIOD_MONTH);
|
||||||
|
const period2 = await getOpenPeriod(prisma, seedTestTenantId, PERIOD_YEAR, PERIOD_MONTH);
|
||||||
|
expect(period1.id).toBe(period2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDateInClosedPeriod returns false for an OPEN period", async () => {
|
||||||
|
const date = new Date(PERIOD_YEAR, PERIOD_MONTH - 1, 15); // June 15, 2025
|
||||||
|
const isClosed = await isDateInClosedPeriod(prisma, seedTestTenantId, date);
|
||||||
|
expect(isClosed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDateInClosedPeriod returns false for a period that has not been created yet", async () => {
|
||||||
|
// December 2099 — no period exists for this month
|
||||||
|
const farFutureDate = new Date(2099, 11, 1);
|
||||||
|
const isClosed = await isDateInClosedPeriod(prisma, seedTestTenantId, farFutureDate);
|
||||||
|
expect(isClosed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closePeriod sets status to CLOSED and records closedAt timestamp", async () => {
|
||||||
|
// Create a user to act as the closer
|
||||||
|
const closer = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `closer-${TEST_TIMESTAMP}@test.example`,
|
||||||
|
passwordHash: "hashed",
|
||||||
|
firstName: "Admin",
|
||||||
|
lastName: "Closer",
|
||||||
|
tenantId: seedTestTenantId,
|
||||||
|
roles: ["ADMIN"],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const beforeClose = new Date();
|
||||||
|
const closed = await closePeriod(prisma, periodId, closer.id);
|
||||||
|
const afterClose = new Date();
|
||||||
|
|
||||||
|
expect(closed.status).toBe("CLOSED");
|
||||||
|
expect(closed.closedById).toBe(closer.id);
|
||||||
|
expect(closed.closedAt).not.toBeNull();
|
||||||
|
expect(closed.closedAt!.getTime()).toBeGreaterThanOrEqual(beforeClose.getTime());
|
||||||
|
expect(closed.closedAt!.getTime()).toBeLessThanOrEqual(afterClose.getTime());
|
||||||
|
|
||||||
|
// Cleanup closer user
|
||||||
|
await prisma.user.delete({ where: { id: closer.id } }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closePeriod throws when period is already closed", async () => {
|
||||||
|
// periodId is now CLOSED from the previous test
|
||||||
|
await expect(closePeriod(prisma, periodId, "some-user-id")).rejects.toThrow(
|
||||||
|
/already closed/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDateInClosedPeriod returns true for a CLOSED period", async () => {
|
||||||
|
const date = new Date(PERIOD_YEAR, PERIOD_MONTH - 1, 20); // June 20, 2025
|
||||||
|
const isClosed = await isDateInClosedPeriod(prisma, seedTestTenantId, date);
|
||||||
|
expect(isClosed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOpenPeriod throws when trying to get a CLOSED period", async () => {
|
||||||
|
await expect(
|
||||||
|
getOpenPeriod(prisma, seedTestTenantId, PERIOD_YEAR, PERIOD_MONTH)
|
||||||
|
).rejects.toThrow(/closed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// INTEGRATION: createTenant auto-provisions COA
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("createTenant COA auto-provisioning (integration)", () => {
|
||||||
|
it("creates a new tenant with a full Chart of Accounts automatically", async () => {
|
||||||
|
const TENANT_EMAIL = `coa-integration-${TEST_TIMESTAMP}@test.example`;
|
||||||
|
|
||||||
|
const result = await createTenant({
|
||||||
|
businessName: `COA Integration ISP ${TEST_TIMESTAMP}`,
|
||||||
|
ownerFirstName: "COA",
|
||||||
|
ownerLastName: "Test",
|
||||||
|
ownerEmail: TENANT_EMAIL,
|
||||||
|
password: "SecurePass123",
|
||||||
|
});
|
||||||
|
|
||||||
|
integrationTenantId = result.tenant.id;
|
||||||
|
|
||||||
|
// Verify the COA was created for this tenant
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: integrationTenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have exactly the ISP COA count of accounts
|
||||||
|
expect(accounts.length).toBe(ISP_CHART_OF_ACCOUNTS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-provisioned accounts cover all 5 account types", async () => {
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: integrationTenantId },
|
||||||
|
select: { accountType: true },
|
||||||
|
});
|
||||||
|
const types = new Set(accounts.map((a) => a.accountType));
|
||||||
|
expect(types.has("ASSET")).toBe(true);
|
||||||
|
expect(types.has("LIABILITY")).toBe(true);
|
||||||
|
expect(types.has("EQUITY")).toBe(true);
|
||||||
|
expect(types.has("REVENUE")).toBe(true);
|
||||||
|
expect(types.has("EXPENSE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-provisioned accounts are all isSystemAccount = true", async () => {
|
||||||
|
const nonSystem = await prisma.account.findMany({
|
||||||
|
where: { tenantId: integrationTenantId, isSystemAccount: false },
|
||||||
|
});
|
||||||
|
expect(nonSystem.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-provisioned COA has correct Subscription Revenue account", async () => {
|
||||||
|
const subscriptionRevenue = await prisma.account.findFirst({
|
||||||
|
where: { tenantId: integrationTenantId, code: "4010" },
|
||||||
|
});
|
||||||
|
expect(subscriptionRevenue).not.toBeNull();
|
||||||
|
expect(subscriptionRevenue?.name).toBe("Subscription Revenue");
|
||||||
|
expect(subscriptionRevenue?.accountType).toBe("REVENUE");
|
||||||
|
expect(subscriptionRevenue?.normalBalance).toBe("CREDIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tenant and COA are isolated per tenant (no cross-tenant leakage)", async () => {
|
||||||
|
// Accounts from integration tenant must not appear under seed test tenant
|
||||||
|
const seedTenantAccounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: seedTestTenantId },
|
||||||
|
});
|
||||||
|
const integrationTenantAccounts = await prisma.account.findMany({
|
||||||
|
where: { tenantId: integrationTenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const seedIds = new Set(seedTenantAccounts.map((a) => a.id));
|
||||||
|
const integrationIds = new Set(integrationTenantAccounts.map((a) => a.id));
|
||||||
|
|
||||||
|
// No overlap in account IDs between tenants
|
||||||
|
for (const id of integrationIds) {
|
||||||
|
expect(seedIds.has(id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/lib/accounting/seed-coa.ts
Normal file
63
src/lib/accounting/seed-coa.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Chart of Accounts Seeder
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Provisions the standard ISP Chart of Accounts for a new tenant.
|
||||||
|
// Called inside createTenant()'s $transaction block to ensure atomic
|
||||||
|
// tenant + COA creation (either both succeed or neither does).
|
||||||
|
//
|
||||||
|
// IMPORTANT: This function receives a Prisma *transaction client* (tx),
|
||||||
|
// not a full PrismaClient. It must work within the existing transaction.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { ISP_CHART_OF_ACCOUNTS } from "./chart-of-accounts";
|
||||||
|
import { AccountType, NormalBalance, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the Prisma interactive transaction callback client.
|
||||||
|
* The Prisma.$transaction callback receives a client with the same API
|
||||||
|
* as the full PrismaClient minus $connect/$disconnect/$on/$transaction/$extends.
|
||||||
|
*/
|
||||||
|
type TxClient = Omit<
|
||||||
|
Parameters<Parameters<import("@prisma/client").PrismaClient["$transaction"]>[0]>[0],
|
||||||
|
never
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the standard ISP Chart of Accounts for a newly created tenant.
|
||||||
|
*
|
||||||
|
* Iterates ISP_CHART_OF_ACCOUNTS in definition order (parents before children)
|
||||||
|
* and creates an Account record for each entry. Parent codes are resolved to
|
||||||
|
* parentId by looking up already-created accounts in a local code→id map.
|
||||||
|
*
|
||||||
|
* Must be called inside an existing Prisma transaction. The caller is
|
||||||
|
* responsible for committing or rolling back the transaction.
|
||||||
|
*
|
||||||
|
* @param tx - The Prisma transaction client (from prisma.$transaction callback)
|
||||||
|
* @param tenantId - The UUID of the newly created tenant
|
||||||
|
* @returns The number of Account records created
|
||||||
|
*/
|
||||||
|
export async function seedChartOfAccounts(tx: TxClient, tenantId: string): Promise<number> {
|
||||||
|
// Map of account code → database ID for resolving parent relationships
|
||||||
|
const codeToId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const definition of ISP_CHART_OF_ACCOUNTS) {
|
||||||
|
const parentId = definition.parentCode ? (codeToId.get(definition.parentCode) ?? null) : null;
|
||||||
|
|
||||||
|
const account = await tx.account.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
code: definition.code,
|
||||||
|
name: definition.name,
|
||||||
|
accountType: definition.accountType as AccountType,
|
||||||
|
normalBalance: definition.normalBalance as NormalBalance,
|
||||||
|
parentId,
|
||||||
|
isSystemAccount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
codeToId.set(definition.code, account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ISP_CHART_OF_ACCOUNTS.length;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { TenantStatus } from "@prisma/client";
|
import { TenantStatus } from "@prisma/client";
|
||||||
|
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tenant Service
|
// Tenant Service
|
||||||
@@ -192,6 +193,11 @@ export async function createTenant(input: CreateTenantInput): Promise<CreateTena
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Provision the standard ISP Chart of Accounts for this tenant.
|
||||||
|
// All 28 COA accounts are created atomically within this transaction —
|
||||||
|
// if anything fails, neither the tenant, user, nor COA are persisted.
|
||||||
|
await seedChartOfAccounts(tx, tenant.id);
|
||||||
|
|
||||||
return { tenant, user };
|
return { tenant, user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user