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