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,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);
}
});
});

View 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;
}

View File

@@ -1,6 +1,7 @@
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { TenantStatus } from "@prisma/client";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
// =============================================================================
// 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 };
});