From 837b7f1979f566c2fe0473f0a820f03368e324cf Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 23:12:48 +0800 Subject: [PATCH] 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 --- .../migration.sql | 79 +++ prisma/schema.prisma | 85 +++ src/lib/accounting/journal-entry-service.ts | 594 ++++++++++++++++++ src/lib/prisma-tenant.ts | 130 +++- 4 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260304150817_add_journal_entry_models/migration.sql create mode 100644 src/lib/accounting/journal-entry-service.ts diff --git a/prisma/migrations/20260304150817_add_journal_entry_models/migration.sql b/prisma/migrations/20260304150817_add_journal_entry_models/migration.sql new file mode 100644 index 0000000..d16b72e --- /dev/null +++ b/prisma/migrations/20260304150817_add_journal_entry_models/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9af9090..60aca89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,19 @@ enum BillingType { POSTPAID } +enum JournalEntryStatus { + DRAFT + PENDING_APPROVAL + APPROVED + POSTED + REVERSED +} + +enum JournalEntrySource { + SYSTEM + MANUAL +} + // ============================================================================= // MODELS // ============================================================================= @@ -119,6 +132,8 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + journalEntryLines JournalEntryLine[] + /// Account codes must be unique within a tenant @@unique([tenantId, code]) /// RLS-ready index — always present on tenant-scoped models @@ -252,6 +267,11 @@ model User { /// Accounting periods this user has closed (as administrator) 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()) updatedAt DateTime @updatedAt @@ -260,3 +280,68 @@ model User { /// RLS-ready index — always present on tenant-scoped models @@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]) +} diff --git a/src/lib/accounting/journal-entry-service.ts b/src/lib/accounting/journal-entry-service.ts new file mode 100644 index 0000000..1322fd5 --- /dev/null +++ b/src/lib/accounting/journal-entry-service.ts @@ -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 { + 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 { + 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 { + 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(); + 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; + } +} diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 0528590..2534695 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -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); + }, + }, }, }); }