From 7c0caf5244a0b635acdb3db4c70d947b51a0a262 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 22:47:45 +0800 Subject: [PATCH] feat(02-01): Account and AccountingPeriod Prisma models + COA definition - Add AccountType, NormalBalance, PeriodStatus enums to schema - Add Account model with tenant scoping, code/name/type/normalBalance/parentId - Add AccountingPeriod model with year/month/status/closedAt/closedById - Create ISP_CHART_OF_ACCOUNTS with 28 accounts across all 5 types (1000-5000 ranges) - Create accounting-period.ts with getOpenPeriod, closePeriod, isDateInClosedPeriod - Extend TENANT_SCOPED_MODELS with account and accountingPeriod - Add full query extension blocks for account and accountingPeriod in withTenantContext - Run migration: 20260304144656_add_accounting_models --- .../migration.sql | 57 ++++ prisma/schema.prisma | 71 +++++ src/lib/accounting/accounting-period.ts | 131 ++++++++ src/lib/accounting/chart-of-accounts.ts | 279 ++++++++++++++++++ src/lib/prisma-tenant.ts | 194 +++++++++++- 5 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260304144656_add_accounting_models/migration.sql create mode 100644 src/lib/accounting/accounting-period.ts create mode 100644 src/lib/accounting/chart-of-accounts.ts diff --git a/prisma/migrations/20260304144656_add_accounting_models/migration.sql b/prisma/migrations/20260304144656_add_accounting_models/migration.sql new file mode 100644 index 0000000..0e1f097 --- /dev/null +++ b/prisma/migrations/20260304144656_add_accounting_models/migration.sql @@ -0,0 +1,57 @@ +-- CreateEnum +CREATE TYPE "AccountType" AS ENUM ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'); + +-- CreateEnum +CREATE TYPE "NormalBalance" AS ENUM ('DEBIT', 'CREDIT'); + +-- CreateEnum +CREATE TYPE "PeriodStatus" AS ENUM ('OPEN', 'CLOSED'); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "accountType" "AccountType" NOT NULL, + "normalBalance" "NormalBalance" NOT NULL, + "parentId" TEXT, + "isSystemAccount" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AccountingPeriod" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "status" "PeriodStatus" NOT NULL DEFAULT 'OPEN', + "closedAt" TIMESTAMP(3), + "closedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccountingPeriod_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Account_tenantId_idx" ON "Account"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_tenantId_code_key" ON "Account"("tenantId", "code"); + +-- CreateIndex +CREATE INDEX "AccountingPeriod_tenantId_idx" ON "AccountingPeriod"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountingPeriod_tenantId_year_month_key" ON "AccountingPeriod"("tenantId", "year", "month"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountingPeriod" ADD CONSTRAINT "AccountingPeriod_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da6b6b4..6c78a68 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,24 @@ enum Role { CLIENT } +enum AccountType { + ASSET + LIABILITY + EQUITY + REVENUE + EXPENSE +} + +enum NormalBalance { + DEBIT + CREDIT +} + +enum PeriodStatus { + OPEN + CLOSED +} + // ============================================================================= // MODELS // ============================================================================= @@ -69,6 +87,56 @@ model Tenant { users User[] } +/// An Account represents a node in the Chart of Accounts tree. +/// Account balances are NEVER stored — they are always derived from journal entry sums. +/// System accounts (isSystemAccount=true) are auto-created from ISP_CHART_OF_ACCOUNTS +/// and cannot be deleted by tenant admins. +model Account { + id String @id @default(uuid()) + tenantId String + /// Account code (e.g., "1010" for Cash on Hand). Unique per tenant. + code String + name String + accountType AccountType + normalBalance NormalBalance + /// Optional parent account id for hierarchical COA (e.g., 1010 parent = 1000) + parentId String? + parent Account? @relation("AccountSubAccounts", fields: [parentId], references: [id]) + children Account[] @relation("AccountSubAccounts") + /// COA accounts seeded at tenant creation cannot be deleted by admins + isSystemAccount Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Account codes must be unique within a tenant + @@unique([tenantId, code]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// An AccountingPeriod represents one calendar month for a tenant. +/// Once CLOSED, no journal entries may be posted to that period. +/// Periods are created on-demand (first time an entry needs one). +model AccountingPeriod { + id String @id @default(uuid()) + tenantId String + year Int + month Int + status PeriodStatus @default(OPEN) + /// Timestamp when the period was closed (null if OPEN) + closedAt DateTime? + /// The admin user who closed this period + closedById String? + closedBy User? @relation(fields: [closedById], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// One period per month per tenant + @@unique([tenantId, year, month]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + /// A User belongs to a Tenant (or is a super-admin with no tenant). /// Email uniqueness is enforced per-tenant, not globally. /// Super-admins have isSuperAdmin=true and tenantId=null. @@ -89,6 +157,9 @@ model User { isActive Boolean @default(true) isSuperAdmin Boolean @default(false) + /// Accounting periods this user has closed (as administrator) + closedPeriods AccountingPeriod[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/lib/accounting/accounting-period.ts b/src/lib/accounting/accounting-period.ts new file mode 100644 index 0000000..9d35154 --- /dev/null +++ b/src/lib/accounting/accounting-period.ts @@ -0,0 +1,131 @@ +// ============================================================================= +// Accounting Period Management +// ============================================================================= +// +// Accounting periods represent calendar months. A period is OPEN by default. +// Once CLOSED, no journal entries may be posted to that period (enforced in 02-02). +// +// Periods are created on-demand — the first call to getOpenPeriod() for a given +// month/year creates the period record if it doesn't already exist. +// ============================================================================= + +import { PrismaClient, PeriodStatus } from "@prisma/client"; + +/** + * Minimal Prisma client interface that supports accountingPeriod operations. + * Accepts a full PrismaClient or a transaction client (Prisma.$transaction callback arg). + */ +type PrismaLike = Pick; + +/** + * Finds an existing OPEN period for the given month/year, or creates one if + * it doesn't exist yet. Throws if the period for that month is already CLOSED. + * + * @param db - A Prisma client or transaction client + * @param tenantId - The tenant scoping this period + * @param year - Calendar year (e.g., 2026) + * @param month - Calendar month, 1-12 + * @returns The OPEN AccountingPeriod record + * @throws Error if period for month/year exists but is CLOSED + */ +export async function getOpenPeriod( + db: PrismaLike, + tenantId: string, + year: number, + month: number +) { + // Try to find an existing period for this month + const existing = await db.accountingPeriod.findFirst({ + where: { tenantId, year, month }, + }); + + if (existing) { + if (existing.status === PeriodStatus.CLOSED) { + throw new Error( + `Accounting period ${year}-${String(month).padStart(2, "0")} is closed. ` + + `No journal entries may be posted to a closed period.` + ); + } + return existing; + } + + // Create the period if it doesn't exist yet + return db.accountingPeriod.create({ + data: { + tenantId, + year, + month, + status: PeriodStatus.OPEN, + }, + }); +} + +/** + * Closes an accounting period. Sets status to CLOSED, records the timestamp + * and the ID of the admin who performed the close. + * + * @param db - A Prisma client or transaction client + * @param periodId - The UUID of the AccountingPeriod to close + * @param closedById - The UUID of the User performing the close action + * @returns The updated (closed) AccountingPeriod record + * @throws Error if the period is already closed + */ +export async function closePeriod( + db: PrismaLike, + periodId: string, + closedById: string +) { + const period = await db.accountingPeriod.findFirst({ + where: { id: periodId }, + }); + + if (!period) { + throw new Error(`Accounting period not found: ${periodId}`); + } + + if (period.status === PeriodStatus.CLOSED) { + throw new Error( + `Accounting period ${period.year}-${String(period.month).padStart(2, "0")} ` + + `is already closed.` + ); + } + + return db.accountingPeriod.update({ + where: { id: periodId }, + data: { + status: PeriodStatus.CLOSED, + closedAt: new Date(), + closedById, + }, + }); +} + +/** + * Checks whether the accounting period containing the given date is CLOSED. + * + * Used by journal entry posting logic (02-02) to prevent entries in closed periods. + * + * @param db - A Prisma client or transaction client + * @param tenantId - The tenant to check + * @param date - The date to check (uses its year and month) + * @returns true if the period is CLOSED, false if OPEN or not yet created + */ +export async function isDateInClosedPeriod( + db: PrismaLike, + tenantId: string, + date: Date +): Promise { + const year = date.getFullYear(); + const month = date.getMonth() + 1; // JS months are 0-indexed + + const period = await db.accountingPeriod.findFirst({ + where: { tenantId, year, month }, + }); + + if (!period) { + // Period not created yet — it's implicitly open + return false; + } + + return period.status === PeriodStatus.CLOSED; +} diff --git a/src/lib/accounting/chart-of-accounts.ts b/src/lib/accounting/chart-of-accounts.ts new file mode 100644 index 0000000..cc877b2 --- /dev/null +++ b/src/lib/accounting/chart-of-accounts.ts @@ -0,0 +1,279 @@ +// ============================================================================= +// ISP Chart of Accounts Definition +// ============================================================================= +// +// This module defines the standard Chart of Accounts (COA) for ISP businesses +// using NetForge. Every new tenant gets this COA auto-provisioned on signup. +// +// DOUBLE-ENTRY ACCOUNTING RULES: +// - DEBIT increases ASSET and EXPENSE accounts (normal balance = DEBIT) +// - CREDIT increases LIABILITY, EQUITY, and REVENUE accounts (normal balance = CREDIT) +// - Account balances are NEVER stored — always derived from journal entry sums +// +// ACCOUNT CODE RANGES: +// 1000s = Assets +// 2000s = Liabilities +// 3000s = Equity +// 4000s = Revenue +// 5000s = Expenses +// ============================================================================= + +/** + * Account type enum mirroring the Prisma AccountType enum. + * Exported for use in code that runs outside the Prisma context (e.g., seeding scripts). + */ +export type AccountType = "ASSET" | "LIABILITY" | "EQUITY" | "REVENUE" | "EXPENSE"; + +/** + * Normal balance enum mirroring the Prisma NormalBalance enum. + * Determines whether a debit or credit increases the account balance. + * - DEBIT: assets and expenses increase with debits + * - CREDIT: liabilities, equity, and revenue increase with credits + */ +export type NormalBalance = "DEBIT" | "CREDIT"; + +/** + * A single account definition in the ISP Chart of Accounts. + */ +export interface COAAccountDefinition { + /** Account code (e.g., "1010"). Must be unique within a tenant. */ + code: string; + /** Human-readable account name (e.g., "Cash on Hand"). */ + name: string; + /** The broad classification of this account. */ + accountType: AccountType; + /** Whether debits or credits increase this account's balance. */ + normalBalance: NormalBalance; + /** + * Optional parent account code. If set, this account is a sub-account + * of the named parent. Parent must appear earlier in the array so it + * can be resolved to a parentId during seeding. + */ + parentCode?: string; +} + +/** + * The standard ISP Chart of Accounts. + * + * Organized by account type: + * - Assets (1000s): What the ISP owns or is owed + * - Liabilities (2000s): What the ISP owes to others + * - Equity (3000s): Owner's stake in the business + * - Revenue (4000s): Income from ISP operations + * - Expenses (5000s): Costs of running the ISP + * + * All 5 standard accounting types are represented. + * Subscriber Credits (1150) is a contra-asset (reduces AR). + */ +export const ISP_CHART_OF_ACCOUNTS: COAAccountDefinition[] = [ + // --------------------------------------------------------------------------- + // ASSETS (1000s) — Normal balance: DEBIT + // --------------------------------------------------------------------------- + { + code: "1000", + name: "Current Assets", + accountType: "ASSET", + normalBalance: "DEBIT", + }, + { + code: "1010", + name: "Cash on Hand", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, + { + code: "1020", + name: "Cash in Bank", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, + { + code: "1100", + name: "Accounts Receivable", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, + { + code: "1150", + name: "Subscriber Credits", + accountType: "ASSET", + normalBalance: "CREDIT", // Contra-asset: reduces accounts receivable + parentCode: "1000", + }, + { + code: "1200", + name: "Equipment Inventory", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, + { + code: "1300", + name: "Prepaid Expenses", + accountType: "ASSET", + normalBalance: "DEBIT", + parentCode: "1000", + }, + + // --------------------------------------------------------------------------- + // LIABILITIES (2000s) — Normal balance: CREDIT + // --------------------------------------------------------------------------- + { + code: "2000", + name: "Current Liabilities", + accountType: "LIABILITY", + normalBalance: "CREDIT", + }, + { + code: "2010", + name: "Accounts Payable", + accountType: "LIABILITY", + normalBalance: "CREDIT", + parentCode: "2000", + }, + { + code: "2100", + name: "Unearned Revenue", + accountType: "LIABILITY", + normalBalance: "CREDIT", // Prepaid subscriber payments not yet earned + parentCode: "2000", + }, + { + code: "2200", + name: "Taxes Payable", + accountType: "LIABILITY", + normalBalance: "CREDIT", + parentCode: "2000", + }, + + // --------------------------------------------------------------------------- + // EQUITY (3000s) — Normal balance: CREDIT + // --------------------------------------------------------------------------- + { + code: "3000", + name: "Owner's Equity", + accountType: "EQUITY", + normalBalance: "CREDIT", + }, + { + code: "3010", + name: "Owner's Capital", + accountType: "EQUITY", + normalBalance: "CREDIT", + parentCode: "3000", + }, + { + code: "3020", + name: "Retained Earnings", + accountType: "EQUITY", + normalBalance: "CREDIT", + parentCode: "3000", + }, + + // --------------------------------------------------------------------------- + // REVENUE (4000s) — Normal balance: CREDIT + // --------------------------------------------------------------------------- + { + code: "4000", + name: "Operating Revenue", + accountType: "REVENUE", + normalBalance: "CREDIT", + }, + { + code: "4010", + name: "Subscription Revenue", + accountType: "REVENUE", + normalBalance: "CREDIT", + parentCode: "4000", + }, + { + code: "4020", + name: "Installation Fees", + accountType: "REVENUE", + normalBalance: "CREDIT", + parentCode: "4000", + }, + { + code: "4030", + name: "Reconnection Fees", + accountType: "REVENUE", + normalBalance: "CREDIT", + parentCode: "4000", + }, + { + code: "4090", + name: "Other Revenue", + accountType: "REVENUE", + normalBalance: "CREDIT", + parentCode: "4000", + }, + + // --------------------------------------------------------------------------- + // EXPENSES (5000s) — Normal balance: DEBIT + // --------------------------------------------------------------------------- + { + code: "5000", + name: "Operating Expenses", + accountType: "EXPENSE", + normalBalance: "DEBIT", + }, + { + code: "5010", + name: "Salary Expense", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5020", + name: "Technician Compensation", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5030", + name: "Equipment Expense", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5040", + name: "Internet Bandwidth", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5050", + name: "Office Supplies", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5060", + name: "Utilities", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5070", + name: "Depreciation", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, + { + code: "5090", + name: "Other Expense", + accountType: "EXPENSE", + normalBalance: "DEBIT", + parentCode: "5000", + }, +]; diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 3fdd88f..f876b49 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma"; * Extend this list as new models are added in later phases: * e.g., "subscriber", "invoice", "servicePlan", "payment" */ -export const TENANT_SCOPED_MODELS = ["user"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -162,6 +162,198 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + account: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.account.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.account.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, + + accountingPeriod: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.accountingPeriod.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.accountingPeriod.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, }, }); }