import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { journalEntryLifecycle } from "../db/journalEntry.lifecycle.generated"; import type { JournalEntrySourceDocumentType, JournalEntryStatus } from "../generated/enums"; import { CompanyNotFoundError, AccountingPeriodNotFoundError, AccountingPeriodCompanyMismatchError, InvalidEntryDateError, AccountNotFoundError, AccountInactiveError, InvalidDebitCreditError, MinimumLinesNotMetError, InvalidStatusForUpdateError, EmptyJournalEntryChangesError, JournalLineNotFoundError, InvalidStatusForPostingError, InvalidPeriodStatusError, UnbalancedEntryError, InvalidStatusForCancelError, InvalidStatusForReversalError, AlreadyReversedError, } from "../lib/errors.generated"; import type { AccountingPeriod } from "./accountingPeriod"; export interface JournalEntry { readonly id: string; readonly companyId: string; readonly accountingPeriodId: string; readonly entryDate: Date; readonly status: JournalEntryStatus; readonly description: string | null; readonly sourceDocumentType: JournalEntrySourceDocumentType | null; readonly sourceDocumentId: string | null; readonly reversalOfId: string | null; readonly postedAt: Date | null; } export interface JournalLine { readonly id: string; readonly accountId: string; readonly debitAmount: string | null; readonly creditAmount: string | null; readonly description: string | null; } export interface AccountReference { readonly id: string; readonly status: string; } export interface JournalEntryHeaderInput { companyId: string; accountingPeriodId: string; entryDate: Date; description?: string; sourceDocumentType?: | "ACCOUNT_PAYABLE_DOCUMENT" | "OUTGOING_PAYMENT" | "INCOMING_PAYMENT" | "BANK_RECONCILIATION" | "SALES_INVOICE" | "INVENTORY_LEDGER" | "STANDARD_COST_REVISION" | "ACQUISITION_COST_ADJUSTMENT" | "PRODUCTION_ORDER" | "YEAR_END_CLOSE"; sourceDocumentId?: string; } export interface CreateJournalEntryInput { header: JournalEntryHeaderInput; lines: JournalLineInput[]; } export interface JournalLineInput { accountId: string; debitAmount: string | null; creditAmount: string | null; description: string | null; } function validateDebitCredit( identifier: string, debitAmount: string | null, creditAmount: string | null, ) { if (debitAmount != null && creditAmount != null) { return err(new InvalidDebitCreditError(identifier)); } if (debitAmount == null && creditAmount == null) { return err(new InvalidDebitCreditError(identifier)); } if (debitAmount != null && new Decimal(debitAmount).lte(0)) { return err(new InvalidDebitCreditError(identifier)); } if (creditAmount != null && new Decimal(creditAmount).lte(0)) { return err(new InvalidDebitCreditError(identifier)); } return ok({}); } export type JournalEntryHeaderPatch = { description?: string | null; sourceDocumentType?: | "ACCOUNT_PAYABLE_DOCUMENT" | "OUTGOING_PAYMENT" | "INCOMING_PAYMENT" | "SALES_INVOICE" | "INVENTORY_LEDGER" | "PRODUCTION_ORDER" | "YEAR_END_CLOSE" | null; sourceDocumentId?: string | null; entryDate?: Date; }; export type JournalLinePatch = Partial; export interface JournalLineEdit { lineId: string; patch: JournalLinePatch; } export type UpdateJournalEntryInput = { id: string; headerPatch?: JournalEntryHeaderPatch; addLines?: JournalLineInput[]; updateLines?: JournalLineEdit[]; removeLineIds?: string[]; }; export function validateCreation( header: JournalEntryHeaderInput, lines: readonly JournalLineInput[], companyExists: boolean, period: AccountingPeriod | null, ) { const { companyId, accountingPeriodId, entryDate } = header; if (!companyExists) return err(new CompanyNotFoundError(companyId)); if (!period) return err(new AccountingPeriodNotFoundError(accountingPeriodId)); if (period.companyId !== companyId) return err(new AccountingPeriodCompanyMismatchError(accountingPeriodId)); if (!(entryDate instanceof Date) || isNaN(entryDate.getTime())) return err(new InvalidEntryDateError(String(entryDate))); if (lines.length < 2) return err(new MinimumLinesNotMetError(accountingPeriodId)); return ok({}); } function validateAccount(accountId: string, account: AccountReference | undefined) { if (!account) return err(new AccountNotFoundError(accountId)); if (account.status !== "ACTIVE") return err(new AccountInactiveError(accountId)); return ok({}); } export function validateAccounts( lines: readonly JournalLineInput[], accounts: readonly AccountReference[], ) { const accountById = new Map(accounts.map((account) => [account.id, account])); for (const line of lines) { const valid = validateAccount(line.accountId, accountById.get(line.accountId)); if (!valid.ok) return valid; } return ok({}); } export function validateDraftLines( identifier: string, lines: readonly JournalLineInput[], accounts: readonly AccountReference[], ) { const accountById = new Map(accounts.map((account) => [account.id, account])); for (const line of lines) { const valid = validateAccount(line.accountId, accountById.get(line.accountId)); if (!valid.ok) return valid; const amounts = validateDebitCredit(identifier, line.debitAmount, line.creditAmount); if (!amounts.ok) return amounts; } return ok({}); } export function createDraft(header: JournalEntryHeaderInput & HCF) { const { companyId, accountingPeriodId, entryDate, description, sourceDocumentType, sourceDocumentId, ...customFields } = header; return { ...customFields, companyId, accountingPeriodId, entryDate, status: "DRAFT" as const, description: description ?? null, sourceDocumentType: sourceDocumentType ?? null, sourceDocumentId: sourceDocumentId ?? null, reversalOfId: null, postedAt: null, }; } export function validateDraftChanges(existing: JournalEntry, input: UpdateJournalEntryInput) { const { id, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input; const headerChangeCount = Object.keys(headerPatch ?? {}).length; const lineChangeCount = addLines.length + updateLines.length + removeLineIds.length; if (existing.status !== "DRAFT") { return err(new InvalidStatusForUpdateError(id)); } if (headerChangeCount === 0 && lineChangeCount === 0) { return err(new EmptyJournalEntryChangesError(id)); } if ( headerPatch?.entryDate !== undefined && (!(headerPatch.entryDate instanceof Date) || isNaN(headerPatch.entryDate.getTime())) ) { return err(new InvalidEntryDateError(String(headerPatch.entryDate))); } return ok({}); } export function prepareLines( id: string, currentLines: readonly JournalLine[], changes: { addLines?: (JournalLineInput & LCF)[]; updateLines?: { lineId: string; patch: JournalLinePatch & Partial }[]; removeLineIds?: string[]; }, ) { const { addLines = [], updateLines = [], removeLineIds = [] } = changes; const currentLineById = new Map(currentLines.map((line) => [line.id, line])); for (const { lineId } of updateLines) { if (!currentLineById.has(lineId)) { return err(new JournalLineNotFoundError(lineId)); } } for (const lineId of removeLineIds) { if (!currentLineById.has(lineId)) { return err(new JournalLineNotFoundError(lineId)); } } const removeLineIdSet = new Set(removeLineIds); const updateLineById = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); const effectiveLines = [ ...currentLines .filter((line) => !removeLineIdSet.has(line.id)) .map((line) => { const patch = updateLineById.get(line.id); return patch ? { ...line, ...patch } : line; }), ...addLines, ]; if (effectiveLines.length < 2) { return err(new MinimumLinesNotMetError(id)); } return ok(effectiveLines); } export function headerChanges( patch: (JournalEntryHeaderPatch & Partial) | undefined, ) { const { description, sourceDocumentType, sourceDocumentId, entryDate, ...customFields } = patch ?? {}; return { ...customFields, ...(description !== undefined ? { description } : {}), ...(sourceDocumentType !== undefined ? { sourceDocumentType } : {}), ...(sourceDocumentId !== undefined ? { sourceDocumentId } : {}), ...(entryDate !== undefined ? { entryDate } : {}), }; } export function validatePosting( entry: JournalEntry, lines: readonly JournalLineInput[], period: AccountingPeriod | null, ) { if (!journalEntryLifecycle.tryTransition(entry.status, "post")) return err(new InvalidStatusForPostingError(entry.id)); if (lines.length < 2) return err(new MinimumLinesNotMetError(entry.id)); if (!period || period.status !== "OPEN") return err(new InvalidPeriodStatusError(entry.accountingPeriodId)); const debits = lines.reduce((sum, line) => sum.plus(line.debitAmount ?? 0), new Decimal(0)); const credits = lines.reduce((sum, line) => sum.plus(line.creditAmount ?? 0), new Decimal(0)); if (!debits.eq(credits)) return err(new UnbalancedEntryError(entry.id)); return ok({}); } export function validatePostStatus(entry: JournalEntry) { const status = journalEntryLifecycle.tryTransition(entry.status, "post"); if (!status) return err(new InvalidStatusForPostingError(entry.id)); return ok({ status }); } export function cancel(entry: JournalEntry) { const status = journalEntryLifecycle.tryTransition(entry.status, "cancel"); if (!status) return err(new InvalidStatusForCancelError(entry.id)); return ok({ status }); } export function validateReversal(entry: JournalEntry, alreadyReversed: boolean) { if (entry.status !== "POSTED") return err(new InvalidStatusForReversalError(entry.id)); if (alreadyReversed) return err(new AlreadyReversedError(entry.id)); return ok({}); } export function reverse( entry: JournalEntry, lines: readonly JournalLine[], targetPeriodId: string, period: AccountingPeriod | null, alreadyReversed: boolean, now: Date, ) { const allowed = validateReversal(entry, alreadyReversed); if (!allowed.ok) return allowed; if (!period || period.status !== "OPEN") return err(new InvalidPeriodStatusError(targetPeriodId)); return ok({ header: { companyId: entry.companyId, accountingPeriodId: targetPeriodId, entryDate: now, status: "POSTED" as const, description: `Reversal of journal entry ${entry.id}`, sourceDocumentType: entry.sourceDocumentType, sourceDocumentId: entry.sourceDocumentId, reversalOfId: entry.id, postedAt: now, }, lines: lines.map((line) => ({ accountId: line.accountId, debitAmount: line.creditAmount, creditAmount: line.debitAmount, description: line.description, })), }); } export function post( entry: JournalEntry, lines: readonly JournalLineInput[], refs: { period: AccountingPeriod | null; accounts: readonly AccountReference[] }, now: Date, ) { const valid = validatePosting(entry, lines, refs.period); if (!valid.ok) return valid; const validAccounts = validateAccounts(lines, refs.accounts); if (!validAccounts.ok) return validAccounts; return ok({ status: "POSTED" as const, postedAt: now }); }