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
This commit is contained in:
kevin-asprec
2026-03-04 22:47:45 +08:00
parent bd8e83bf68
commit 7c0caf5244
5 changed files with 731 additions and 1 deletions

View File

@@ -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;

View File

@@ -39,6 +39,24 @@ enum Role {
CLIENT CLIENT
} }
enum AccountType {
ASSET
LIABILITY
EQUITY
REVENUE
EXPENSE
}
enum NormalBalance {
DEBIT
CREDIT
}
enum PeriodStatus {
OPEN
CLOSED
}
// ============================================================================= // =============================================================================
// MODELS // MODELS
// ============================================================================= // =============================================================================
@@ -69,6 +87,56 @@ model Tenant {
users User[] 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). /// A User belongs to a Tenant (or is a super-admin with no tenant).
/// Email uniqueness is enforced per-tenant, not globally. /// Email uniqueness is enforced per-tenant, not globally.
/// Super-admins have isSuperAdmin=true and tenantId=null. /// Super-admins have isSuperAdmin=true and tenantId=null.
@@ -89,6 +157,9 @@ model User {
isActive Boolean @default(true) isActive Boolean @default(true)
isSuperAdmin Boolean @default(false) isSuperAdmin Boolean @default(false)
/// Accounting periods this user has closed (as administrator)
closedPeriods AccountingPeriod[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -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<PrismaClient, "accountingPeriod">;
/**
* 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<boolean> {
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;
}

View File

@@ -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",
},
];

View File

@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
* Extend this list as new models are added in later phases: * Extend this list as new models are added in later phases:
* e.g., "subscriber", "invoice", "servicePlan", "payment" * 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]; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -162,6 +162,198 @@ export function withTenantContext(tenantId: string) {
return query(args); 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);
},
},
}, },
}); });
} }