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
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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:
|
||||
* 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user