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:
131
src/lib/accounting/accounting-period.ts
Normal file
131
src/lib/accounting/accounting-period.ts
Normal 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;
|
||||
}
|
||||
279
src/lib/accounting/chart-of-accounts.ts
Normal file
279
src/lib/accounting/chart-of-accounts.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user