feat(02-02): JournalEntry models + JournalEntryService

- Add JournalEntryStatus and JournalEntrySource enums to Prisma schema
- Add JournalEntry model with maker-checker fields, self-referential reversal relation, and audit timestamps
- Add JournalEntryLine model with debit/credit Decimal(15,2) fields
- Update Account model with journalEntryLines back-relation
- Update User model with createdJournalEntries and approvedJournalEntries back-relations
- Run migration: 20260304150817_add_journal_entry_models
- Add journalEntry and journalEntryLine to TENANT_SCOPED_MODELS in prisma-tenant.ts
- Create JournalEntryService with createEntry, approveEntry, reverseEntry, getAccountBalance, getTrialBalance
- Enforce debit=credit balance using integer cents comparison (avoids float issues)
- SYSTEM source entries auto-posted; MANUAL entries start as DRAFT for maker-checker
This commit is contained in:
kevin-asprec
2026-03-04 23:12:48 +08:00
parent 5c6969343b
commit 837b7f1979
4 changed files with 887 additions and 1 deletions

View File

@@ -0,0 +1,594 @@
// =============================================================================
// JournalEntryService — The Sole Gateway to the Accounting Ledger
// =============================================================================
//
// ARCHITECTURE:
// Every financial event in the system (invoice generation, payment recording,
// expense logging) MUST go through this service. No other code may write to
// JournalEntry or JournalEntryLine directly.
//
// DOUBLE-ENTRY ENFORCEMENT:
// All entries must have debits equal to credits. Unbalanced entries are rejected.
//
// IMMUTABILITY:
// Journal entries cannot be updated or deleted. Corrections use reversing entries.
//
// MAKER-CHECKER:
// MANUAL entries are created with status DRAFT and must be approved before posting.
// SYSTEM entries are created directly with status POSTED.
// Self-approval is allowed for single-person ISP operations.
//
// CLOSED PERIOD PROTECTION:
// Entries cannot be posted to a closed accounting period.
//
// TRANSACTION PATTERN:
// The service receives a tenant-scoped Prisma client (withTenantContext) AND the
// raw base Prisma client for transaction coordination. Inside transactions, tenantId
// is passed explicitly because the $transaction callback receives a raw client
// without the tenant extension.
// =============================================================================
import { Prisma, JournalEntrySource, JournalEntryStatus, NormalBalance } from "@prisma/client";
import { isDateInClosedPeriod } from "@/lib/accounting/accounting-period";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A single line in a journal entry.
* Exactly one of debit or credit should be non-zero.
*/
export interface JournalEntryLineInput {
accountId: string;
/** Amount to debit (positive, or 0 if this is a credit line) */
debit: number | string;
/** Amount to credit (positive, or 0 if this is a debit line) */
credit: number | string;
/** Optional memo for this line */
description?: string;
}
export interface CreateEntryInput {
/** Tenant-scoped Prisma client (from withTenantContext) — used for non-transactional queries */
tenantPrisma: TenantPrismaClient;
/** The tenant UUID — needed for explicit tenantId injection inside transactions */
tenantId: string;
date: Date;
description: string;
lines: JournalEntryLineInput[];
source: JournalEntrySource;
referenceType?: string;
referenceId?: string;
createdById: string;
}
export interface ApproveEntryInput {
tenantPrisma: TenantPrismaClient;
entryId: string;
approvedById: string;
}
export interface ReverseEntryInput {
tenantPrisma: TenantPrismaClient;
tenantId: string;
entryId: string;
reversedById: string;
date?: Date;
description?: string;
}
export interface GetAccountBalanceInput {
tenantPrisma: TenantPrismaClient;
accountId: string;
asOfDate?: Date;
}
export interface AccountBalanceResult {
accountId: string;
balance: Prisma.Decimal;
asOfDate: Date | null;
}
export interface TrialBalanceLine {
accountId: string;
accountCode: string;
accountName: string;
debitBalance: Prisma.Decimal;
creditBalance: Prisma.Decimal;
}
/**
* Minimal Prisma client type for journal entry operations.
* Accepts the tenant-scoped Prisma client returned by withTenantContext(),
* or the raw PrismaClient (for use inside $transaction callbacks).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert a line amount (number, string, or Decimal) to integer cents
* to avoid floating-point comparison issues during validation.
*/
function toCents(value: number | string): number {
const n = typeof value === "string" ? parseFloat(value) : Number(value);
// Round to avoid floating point artifacts (e.g., 100.1 * 100 = 10009.999...)
return Math.round(n * 100);
}
/**
* Generate the next entry number for a tenant in a given year.
* Format: "JE-{YYYY}-{NNNN}"
*
* Uses the tenant-scoped client (which auto-filters by tenant).
*/
async function generateEntryNumber(
tenantPrisma: TenantPrismaClient,
year: number
): Promise<string> {
const yearPrefix = `JE-${year}-`;
const existing = await tenantPrisma.journalEntry.findMany({
where: {
entryNumber: {
startsWith: yearPrefix,
},
},
select: { entryNumber: true },
orderBy: { entryNumber: "desc" },
take: 1,
});
let nextNumber = 1;
if (existing.length > 0) {
const lastNumber = existing[0].entryNumber as string;
// Parse the sequence number after "JE-YYYY-"
const seq = lastNumber.slice(yearPrefix.length);
const lastSeq = parseInt(seq, 10);
nextNumber = lastSeq + 1;
}
return `JE-${year}-${String(nextNumber).padStart(4, "0")}`;
}
// ---------------------------------------------------------------------------
// JournalEntryService
// ---------------------------------------------------------------------------
/**
* The sole gateway to the accounting ledger.
*
* All financial events in the system flow through this service.
* Static methods receive a tenant-scoped Prisma client (stateless pattern).
*/
export class JournalEntryService {
/**
* Creates a new journal entry with validation.
*
* Validates:
* - At least 2 lines
* - Each line has either debit > 0 OR credit > 0 (not both, not neither)
* - Sum of debits equals sum of credits
* - All accountIds exist and belong to the tenant
* - The entry date is not in a closed period
*
* SYSTEM source: status = POSTED (auto-approved)
* MANUAL source: status = DRAFT (requires approval)
*/
static async createEntry(input: CreateEntryInput) {
const {
tenantPrisma,
tenantId,
date,
description,
lines,
source,
referenceType,
referenceId,
createdById,
} = input;
// Validate minimum lines
if (lines.length < 2) {
throw new Error("Journal entry must have at least 2 lines.");
}
// Validate each line: exactly one of debit or credit must be non-zero
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const debitCents = toCents(line.debit);
const creditCents = toCents(line.credit);
if (debitCents < 0 || creditCents < 0) {
throw new Error(`Line ${i + 1}: debit and credit amounts must be non-negative.`);
}
if (debitCents > 0 && creditCents > 0) {
throw new Error(
`Line ${i + 1}: a line cannot have both debit and credit amounts. ` +
`Use separate lines for debits and credits.`
);
}
if (debitCents === 0 && creditCents === 0) {
throw new Error(
`Line ${i + 1}: a line must have either a debit or credit amount (not both zero).`
);
}
}
// Validate debit=credit balance (compare in cents to avoid float issues)
const totalDebitCents = lines.reduce((sum, l) => sum + toCents(l.debit), 0);
const totalCreditCents = lines.reduce((sum, l) => sum + toCents(l.credit), 0);
if (totalDebitCents !== totalCreditCents) {
const debitAmt = (totalDebitCents / 100).toFixed(2);
const creditAmt = (totalCreditCents / 100).toFixed(2);
throw new Error(
`Unbalanced journal entry: total debits (${debitAmt}) do not equal total credits (${creditAmt}). ` +
`All journal entries must balance.`
);
}
// Validate all accountIds exist and belong to tenant
const accountIds = [...new Set(lines.map((l) => l.accountId))];
const accounts = await tenantPrisma.account.findMany({
where: { id: { in: accountIds } },
select: { id: true },
});
if (accounts.length !== accountIds.length) {
const foundIds = new Set(accounts.map((a: { id: string }) => a.id));
const missingIds = accountIds.filter((id) => !foundIds.has(id));
throw new Error(
`Account(s) not found or do not belong to this tenant: ${missingIds.join(", ")}`
);
}
// Check closed period
const isClosed = await isDateInClosedPeriod(tenantPrisma, tenantId, date);
if (isClosed) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
throw new Error(
`Cannot post journal entry: accounting period ${year}-${String(month).padStart(2, "0")} is closed.`
);
}
// Generate entry number (must be done before transaction for atomicity of numbering)
const entryNumber = await generateEntryNumber(tenantPrisma, date.getFullYear());
// Determine status based on source
const status =
source === JournalEntrySource.SYSTEM
? JournalEntryStatus.POSTED
: JournalEntryStatus.DRAFT;
// Create the journal entry and all lines in a single transaction.
// Note: Inside $transaction, we receive the raw (non-extended) tx client,
// so tenantId must be passed explicitly.
const entry = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
const created = await tx.journalEntry.create({
data: {
tenantId,
entryNumber,
date,
description,
source,
status,
referenceType: referenceType ?? null,
referenceId: referenceId ?? null,
createdById,
lines: {
create: lines.map((line) => ({
tenantId,
accountId: line.accountId,
debit: new Prisma.Decimal(line.debit),
credit: new Prisma.Decimal(line.credit),
description: line.description ?? null,
})),
},
},
include: {
lines: true,
},
});
return created;
});
return entry;
}
/**
* Approves a manual journal entry, changing status from DRAFT/PENDING_APPROVAL to POSTED.
*
* Self-approval is allowed (single-person ISP operations are common per CONTEXT.md).
*/
static async approveEntry(input: ApproveEntryInput) {
const { tenantPrisma, entryId, approvedById } = input;
const entry = await tenantPrisma.journalEntry.findFirst({
where: { id: entryId },
});
if (!entry) {
throw new Error(`Journal entry not found: ${entryId}`);
}
if (
entry.status !== JournalEntryStatus.DRAFT &&
entry.status !== JournalEntryStatus.PENDING_APPROVAL
) {
throw new Error(
`Cannot approve journal entry with status "${entry.status}". ` +
`Only DRAFT or PENDING_APPROVAL entries can be approved.`
);
}
return tenantPrisma.journalEntry.update({
where: { id: entryId },
data: {
status: JournalEntryStatus.POSTED,
approvedById,
approvedAt: new Date(),
},
include: { lines: true },
});
}
/**
* Reverses an existing journal entry by creating a new entry with swapped debits/credits.
*
* The original entry is marked REVERSED. The reversing entry has source=SYSTEM, status=POSTED.
* All operations are performed in a single transaction for atomicity.
*
* @throws Error if entry not found, or already reversed.
*/
static async reverseEntry(input: ReverseEntryInput) {
const { tenantPrisma, tenantId, entryId, reversedById, date, description } = input;
const original = await tenantPrisma.journalEntry.findFirst({
where: { id: entryId },
include: { lines: true },
});
if (!original) {
throw new Error(`Journal entry not found: ${entryId}`);
}
// Check if already reversed by status
if (original.status === JournalEntryStatus.REVERSED) {
throw new Error(
`Journal entry ${original.entryNumber} has already been reversed.`
);
}
// Check if a reversing entry already exists (via the reversesEntryId FK on the reversing entry)
const alreadyReversing = await tenantPrisma.journalEntry.findFirst({
where: { reversesEntryId: entryId },
});
if (alreadyReversing) {
throw new Error(
`Journal entry ${original.entryNumber} has already been reversed (by ${alreadyReversing.entryNumber}).`
);
}
const reversalDate = date ?? new Date();
const reversalDescription =
description ?? `Reversal of ${original.entryNumber}: ${original.description}`;
// Generate entry number for the reversing entry
const entryNumber = await generateEntryNumber(tenantPrisma, reversalDate.getFullYear());
return tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
// Create the reversing entry with swapped debits/credits
const reversingEntry = await tx.journalEntry.create({
data: {
tenantId,
entryNumber,
date: reversalDate,
description: reversalDescription,
source: JournalEntrySource.SYSTEM,
status: JournalEntryStatus.POSTED,
referenceType: original.referenceType,
referenceId: original.referenceId,
reversesEntryId: original.id,
createdById: reversedById,
lines: {
create: original.lines.map(
(line: {
accountId: string;
debit: Prisma.Decimal;
credit: Prisma.Decimal;
description: string | null;
}) => ({
tenantId,
accountId: line.accountId,
// Swap: original debit becomes credit and vice versa
debit: new Prisma.Decimal(line.credit),
credit: new Prisma.Decimal(line.debit),
description: line.description,
})
),
},
},
include: { lines: true },
});
// Mark the original entry as REVERSED
await tx.journalEntry.update({
where: { id: original.id },
data: {
status: JournalEntryStatus.REVERSED,
},
});
return reversingEntry;
});
}
/**
* Computes the balance of an account by summing POSTED journal entry lines.
*
* Balance is derived (not stored) from journal entry lines.
* Respects normal balance direction:
* - DEBIT normal balance: balance = sum(debit) - sum(credit)
* - CREDIT normal balance: balance = sum(credit) - sum(debit)
*
* @param asOfDate - If provided, only includes entries dated on or before this date
*/
static async getAccountBalance(input: GetAccountBalanceInput): Promise<AccountBalanceResult> {
const { tenantPrisma, accountId, asOfDate } = input;
// Fetch account to determine normal balance direction
const account = await tenantPrisma.account.findFirst({
where: { id: accountId },
select: { id: true, normalBalance: true },
});
if (!account) {
throw new Error(`Account not found: ${accountId}`);
}
// Build date filter for asOfDate
const dateFilter = asOfDate ? { date: { lte: asOfDate } } : {};
// Aggregate debit and credit sums from POSTED journal entry lines
const result = await tenantPrisma.journalEntryLine.aggregate({
where: {
accountId,
journalEntry: {
status: JournalEntryStatus.POSTED,
...dateFilter,
},
},
_sum: {
debit: true,
credit: true,
},
});
const totalDebit = new Prisma.Decimal(result._sum.debit ?? 0);
const totalCredit = new Prisma.Decimal(result._sum.credit ?? 0);
// Calculate balance based on normal balance direction
let balance: Prisma.Decimal;
if (account.normalBalance === NormalBalance.DEBIT) {
balance = totalDebit.minus(totalCredit);
} else {
balance = totalCredit.minus(totalDebit);
}
return {
accountId,
balance,
asOfDate: asOfDate ?? null,
};
}
/**
* Computes a trial balance — balances for all accounts as of a given date.
*
* Returns an array of account balances. The sum of all debit balances
* must equal the sum of all credit balances (self-verifying accounting invariant).
*
* Accounts with zero balance are included for completeness.
*/
static async getTrialBalance(input: {
tenantPrisma: TenantPrismaClient;
asOfDate?: Date;
}): Promise<TrialBalanceLine[]> {
const { tenantPrisma, asOfDate } = input;
// Fetch all accounts for the tenant (ordered by code for consistent output)
const accounts = await tenantPrisma.account.findMany({
select: {
id: true,
code: true,
name: true,
normalBalance: true,
},
orderBy: { code: "asc" },
});
// Build date filter
const dateFilter = asOfDate ? { date: { lte: asOfDate } } : {};
// Aggregate all POSTED lines grouped by account in one query
const lineAggregates = await tenantPrisma.journalEntryLine.groupBy({
by: ["accountId"],
where: {
journalEntry: {
status: JournalEntryStatus.POSTED,
...dateFilter,
},
},
_sum: {
debit: true,
credit: true,
},
});
// Build lookup map from accountId to totals
const aggregateMap = new Map<string, { debit: Prisma.Decimal; credit: Prisma.Decimal }>();
for (const agg of lineAggregates) {
aggregateMap.set(agg.accountId, {
debit: new Prisma.Decimal(agg._sum.debit ?? 0),
credit: new Prisma.Decimal(agg._sum.credit ?? 0),
});
}
// Build trial balance
const trialBalance: TrialBalanceLine[] = accounts.map(
(account: {
id: string;
code: string;
name: string;
normalBalance: NormalBalance;
}) => {
const agg = aggregateMap.get(account.id) ?? {
debit: new Prisma.Decimal(0),
credit: new Prisma.Decimal(0),
};
const totalDebit = new Prisma.Decimal(agg.debit);
const totalCredit = new Prisma.Decimal(agg.credit);
let debitBalance = new Prisma.Decimal(0);
let creditBalance = new Prisma.Decimal(0);
if (account.normalBalance === NormalBalance.DEBIT) {
const net = totalDebit.minus(totalCredit);
if (net.greaterThan(0)) {
debitBalance = net;
} else if (net.lessThan(0)) {
// Abnormal balance — show on the opposite side
creditBalance = net.abs();
}
} else {
const net = totalCredit.minus(totalDebit);
if (net.greaterThan(0)) {
creditBalance = net;
} else if (net.lessThan(0)) {
// Abnormal balance — show on the opposite side
debitBalance = net.abs();
}
}
return {
accountId: account.id,
accountCode: account.code,
accountName: account.name,
debitBalance,
creditBalance,
};
}
);
return trialBalance;
}
}

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", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings"] as const;
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine"] as const;
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -608,6 +608,134 @@ export function withTenantContext(tenantId: string) {
return query(args);
},
},
journalEntry: {
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.journalEntry.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.journalEntry.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 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 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);
},
},
journalEntryLine: {
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.journalEntryLine.findFirst({
...args,
where: { ...args.where, tenantId },
});
}
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
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);
},
},
},
});
}