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