From a53ee9cd1cae71ae3e8b18c0da6fbe87ce1254f3 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 22:51:52 +0800 Subject: [PATCH] feat(02-01): COA auto-provisioning on tenant signup + API routes + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/app/api/accounting/accounts/route.ts | 45 ++ .../accounting/periods/[id]/close/route.ts | 74 ++++ src/app/api/accounting/periods/route.ts | 44 ++ src/lib/__tests__/accounting-coa.test.ts | 400 ++++++++++++++++++ src/lib/accounting/seed-coa.ts | 63 +++ src/lib/tenant.ts | 6 + 6 files changed, 632 insertions(+) create mode 100644 src/app/api/accounting/accounts/route.ts create mode 100644 src/app/api/accounting/periods/[id]/close/route.ts create mode 100644 src/app/api/accounting/periods/route.ts create mode 100644 src/lib/__tests__/accounting-coa.test.ts create mode 100644 src/lib/accounting/seed-coa.ts diff --git a/src/app/api/accounting/accounts/route.ts b/src/app/api/accounting/accounts/route.ts new file mode 100644 index 0000000..231f421 --- /dev/null +++ b/src/app/api/accounting/accounts/route.ts @@ -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); + } +); diff --git a/src/app/api/accounting/periods/[id]/close/route.ts b/src/app/api/accounting/periods/[id]/close/route.ts new file mode 100644 index 0000000..a2514a0 --- /dev/null +++ b/src/app/api/accounting/periods/[id]/close/route.ts @@ -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); +} diff --git a/src/app/api/accounting/periods/route.ts b/src/app/api/accounting/periods/route.ts new file mode 100644 index 0000000..b5ae06c --- /dev/null +++ b/src/app/api/accounting/periods/route.ts @@ -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); + } +); diff --git a/src/lib/__tests__/accounting-coa.test.ts b/src/lib/__tests__/accounting-coa.test.ts new file mode 100644 index 0000000..947005f --- /dev/null +++ b/src/lib/__tests__/accounting-coa.test.ts @@ -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(); + 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); + } + }); +}); diff --git a/src/lib/accounting/seed-coa.ts b/src/lib/accounting/seed-coa.ts new file mode 100644 index 0000000..54c1caa --- /dev/null +++ b/src/lib/accounting/seed-coa.ts @@ -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[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 { + // Map of account code → database ID for resolving parent relationships + const codeToId = new Map(); + + 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; +} diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts index 1e5eefa..d75d00d 100644 --- a/src/lib/tenant.ts +++ b/src/lib/tenant.ts @@ -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