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,79 @@
-- CreateEnum
CREATE TYPE "JournalEntryStatus" AS ENUM ('DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'POSTED', 'REVERSED');
-- CreateEnum
CREATE TYPE "JournalEntrySource" AS ENUM ('SYSTEM', 'MANUAL');
-- CreateTable
CREATE TABLE "JournalEntry" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"entryNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"description" TEXT NOT NULL,
"source" "JournalEntrySource" NOT NULL,
"status" "JournalEntryStatus" NOT NULL DEFAULT 'DRAFT',
"referenceType" TEXT,
"referenceId" TEXT,
"reversesEntryId" TEXT,
"createdById" TEXT NOT NULL,
"approvedById" TEXT,
"approvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "JournalEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JournalEntryLine" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"journalEntryId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"debit" DECIMAL(15,2) NOT NULL DEFAULT 0,
"credit" DECIMAL(15,2) NOT NULL DEFAULT 0,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "JournalEntryLine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "JournalEntry_reversesEntryId_key" ON "JournalEntry"("reversesEntryId");
-- CreateIndex
CREATE INDEX "JournalEntry_tenantId_idx" ON "JournalEntry"("tenantId");
-- CreateIndex
CREATE INDEX "JournalEntry_tenantId_date_idx" ON "JournalEntry"("tenantId", "date");
-- CreateIndex
CREATE INDEX "JournalEntry_referenceType_referenceId_idx" ON "JournalEntry"("referenceType", "referenceId");
-- CreateIndex
CREATE UNIQUE INDEX "JournalEntry_tenantId_entryNumber_key" ON "JournalEntry"("tenantId", "entryNumber");
-- CreateIndex
CREATE INDEX "JournalEntryLine_tenantId_idx" ON "JournalEntryLine"("tenantId");
-- CreateIndex
CREATE INDEX "JournalEntryLine_accountId_idx" ON "JournalEntryLine"("accountId");
-- CreateIndex
CREATE INDEX "JournalEntryLine_journalEntryId_idx" ON "JournalEntryLine"("journalEntryId");
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_reversesEntryId_fkey" FOREIGN KEY ("reversesEntryId") REFERENCES "JournalEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_journalEntryId_fkey" FOREIGN KEY ("journalEntryId") REFERENCES "JournalEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -68,6 +68,19 @@ enum BillingType {
POSTPAID POSTPAID
} }
enum JournalEntryStatus {
DRAFT
PENDING_APPROVAL
APPROVED
POSTED
REVERSED
}
enum JournalEntrySource {
SYSTEM
MANUAL
}
// ============================================================================= // =============================================================================
// MODELS // MODELS
// ============================================================================= // =============================================================================
@@ -119,6 +132,8 @@ model Account {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
journalEntryLines JournalEntryLine[]
/// Account codes must be unique within a tenant /// Account codes must be unique within a tenant
@@unique([tenantId, code]) @@unique([tenantId, code])
/// RLS-ready index — always present on tenant-scoped models /// RLS-ready index — always present on tenant-scoped models
@@ -252,6 +267,11 @@ model User {
/// Accounting periods this user has closed (as administrator) /// Accounting periods this user has closed (as administrator)
closedPeriods AccountingPeriod[] closedPeriods AccountingPeriod[]
/// Journal entries this user created (maker)
createdJournalEntries JournalEntry[] @relation("JournalEntryCreatedBy")
/// Journal entries this user approved (checker)
approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -260,3 +280,68 @@ model User {
/// RLS-ready index — always present on tenant-scoped models /// RLS-ready index — always present on tenant-scoped models
@@index([tenantId]) @@index([tenantId])
} }
/// A JournalEntry records a double-entry accounting transaction.
/// All financial events in the system flow through this model.
/// Entries are IMMUTABLE — no updates or deletes. Corrections use reversing entries.
model JournalEntry {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant per year (e.g., "JE-2026-0001")
entryNumber String
/// The accounting date (when the economic event occurred, not necessarily createdAt)
date DateTime
description String
source JournalEntrySource
status JournalEntryStatus @default(DRAFT)
/// Optional reference to source record (e.g., "Invoice", "Payment")
referenceType String?
/// ID of the source record (e.g., the invoice UUID)
referenceId String?
/// If this entry reverses another entry, this points to the original
reversesEntryId String? @unique
reversesEntry JournalEntry? @relation("JournalEntryReversal", fields: [reversesEntryId], references: [id])
/// If this entry has been reversed, this points to the reversing entry (back-reference)
reversedByEntry JournalEntry? @relation("JournalEntryReversal")
/// The user who created the entry (maker)
createdById String
createdBy User @relation("JournalEntryCreatedBy", fields: [createdById], references: [id])
/// The user who approved the entry (checker — for MANUAL entries)
approvedById String?
approvedBy User? @relation("JournalEntryApprovedBy", fields: [approvedById], references: [id])
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines JournalEntryLine[]
/// Entry numbers must be unique within a tenant
@@unique([tenantId, entryNumber])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, date])
@@index([referenceType, referenceId])
}
/// A single line (debit or credit) within a JournalEntry.
/// Every entry must have at least 2 lines, and sum(debit) = sum(credit).
model JournalEntryLine {
id String @id @default(uuid())
tenantId String
journalEntryId String
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
/// Debit amount for this line (0 if this is a credit line)
debit Decimal @default(0) @db.Decimal(15, 2)
/// Credit amount for this line (0 if this is a debit line)
credit Decimal @default(0) @db.Decimal(15, 2)
/// Optional line-level memo
description String?
createdAt DateTime @default(now())
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([accountId])
@@index([journalEntryId])
}

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: * 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", "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]; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -608,6 +608,134 @@ export function withTenantContext(tenantId: string) {
return query(args); 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);
},
},
}, },
}); });
} }