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:
@@ -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;
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
594
src/lib/accounting/journal-entry-service.ts
Normal file
594
src/lib/accounting/journal-entry-service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user