import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { BankReconciliationItemSourceType } from "../generated/enums"; import type { Transaction } from "../generated/kysely-tailordb"; import { BankAccountInactiveError, BankAccountNotFoundError, BankReconciliationAccountingPeriodNotFoundError, BankReconciliationInvalidError, BankReconciliationJournalFailedError, BankReconciliationOverClearedError, BankReconciliationSourceIneligibleError, BankReconciliationSourceNotFoundError, } from "../lib/errors.generated"; import type { AccountPayableQueries, AccountReceivableQueries, FinancialAccountingCommands, FinancialAccountingQueries, } from "../module"; export interface ReconciliationItemInput { sourceType: BankReconciliationItemSourceType; sourceId: string; clearedAmount: string; } export interface ReconcileBankTransactionsInput { bankAccountId: string; reconciliationDate: Date; items: ReconciliationItemInput[]; } type ResolvedPayment = { sourceId: string; sourceType: "INCOMING_PAYMENT" | "OUTGOING_PAYMENT"; totalAmount: string; paymentAccountId: string; }; type ResolvedStatementLine = { id: string; }; async function getEffectiveClearedAmount( db: Transaction, sourceType: BankReconciliationItemSourceType, sourceId: string, ) { const rows = await db .selectFrom("BankReconciliationItem as item") .innerJoin( "BankReconciliation as reconciliation", "reconciliation.id", "item.bankReconciliationId", ) .select(["item.clearedAmount", "reconciliation.action"]) .where("item.sourceType", "=", sourceType) .where("item.sourceId", "=", sourceId) .execute(); return rows.reduce( (total, row) => row.action === "UNRECONCILE" ? total.minus(row.clearedAmount) : total.plus(row.clearedAmount), new Decimal(0), ); } export async function run( db: Transaction, input: ReconcileBankTransactionsInput, ctx: CommandContext, accountReceivableQueries: Pick, accountPayableQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const { bankAccountId, reconciliationDate, items } = input; const bankAccount = await db .selectFrom("BankAccount") .selectAll() .where("id", "=", bankAccountId) .forUpdate() .executeTakeFirst(); if (!bankAccount) return err(new BankAccountNotFoundError(bankAccountId)); if (bankAccount.status !== "ACTIVE") return err(new BankAccountInactiveError(bankAccountId)); if (items.length < 2) { return err(new BankReconciliationInvalidError(bankAccountId)); } const sourceKeys = new Set(); for (const item of items) { const key = `${item.sourceType}:${item.sourceId}`; if (sourceKeys.has(key) || new Decimal(item.clearedAmount).lte(0)) { return err(new BankReconciliationInvalidError(key)); } sourceKeys.add(key); } const statementItems = items.filter((item) => item.sourceType === "BANK_STATEMENT_LINE"); const incomingItems = items.filter((item) => item.sourceType === "INCOMING_PAYMENT"); const outgoingItems = items.filter((item) => item.sourceType === "OUTGOING_PAYMENT"); if ( statementItems.length === 0 || (incomingItems.length === 0 && outgoingItems.length === 0) || (incomingItems.length > 0 && outgoingItems.length > 0) ) { return err(new BankReconciliationInvalidError(bankAccountId)); } const expectedFlow = incomingItems.length > 0 ? "CREDIT" : "DEBIT"; const resolvedStatementLines: ResolvedStatementLine[] = []; let statementTotal = new Decimal(0); for (const item of statementItems) { const statementLine = await db .selectFrom("BankStatementLine as line") .innerJoin("BankStatement as statement", "statement.id", "line.bankStatementId") .select([ "line.id", "line.amount", "line.reconciliationStatus", "line.currentBankReconciliationId", "line.flowDirection", "statement.bankAccountId", ]) .where("line.id", "=", item.sourceId) .forUpdate() .executeTakeFirst(); if (!statementLine) { return err(new BankReconciliationSourceNotFoundError(item.sourceId)); } if ( statementLine.bankAccountId !== bankAccountId || statementLine.flowDirection !== expectedFlow || statementLine.reconciliationStatus !== "UNRECONCILED" || statementLine.currentBankReconciliationId !== null ) { return err(new BankReconciliationSourceIneligibleError(item.sourceId)); } if (!new Decimal(item.clearedAmount).eq(statementLine.amount)) { return err(new BankReconciliationInvalidError(item.sourceId)); } resolvedStatementLines.push({ id: statementLine.id }); statementTotal = statementTotal.plus(item.clearedAmount); } const resolvedPayments: ResolvedPayment[] = []; let paymentTotal = new Decimal(0); for (const item of [...incomingItems, ...outgoingItems]) { if (item.sourceType === "INCOMING_PAYMENT") { const result = await accountReceivableQueries.getIncomingPayment( db, { id: item.sourceId }, ctx, ); const payment = result.value.incomingPayment; if (!payment) return err(new BankReconciliationSourceNotFoundError(item.sourceId)); if ( payment.status !== "POSTED" || payment.companyId !== bankAccount.companyId || payment.currencyId !== bankAccount.currencyId || (payment.paymentAccountId !== bankAccount.cashAccountId && payment.paymentAccountId !== bankAccount.incomingClearingAccountId) ) { return err(new BankReconciliationSourceIneligibleError(item.sourceId)); } resolvedPayments.push({ sourceId: item.sourceId, sourceType: "INCOMING_PAYMENT", totalAmount: payment.totalAmount, paymentAccountId: payment.paymentAccountId, }); const effective = await getEffectiveClearedAmount(db, item.sourceType, item.sourceId); if (effective.plus(item.clearedAmount).gt(payment.totalAmount)) { return err(new BankReconciliationOverClearedError(item.sourceId)); } } else { const result = await accountPayableQueries.getOutgoingPayment(db, { id: item.sourceId }, ctx); const payment = result.value.outgoingPayment; if (!payment) return err(new BankReconciliationSourceNotFoundError(item.sourceId)); if ( payment.status !== "POSTED" || payment.companyId !== bankAccount.companyId || payment.currencyId !== bankAccount.currencyId || (payment.paymentAccountId !== bankAccount.cashAccountId && payment.paymentAccountId !== bankAccount.outgoingClearingAccountId) ) { return err(new BankReconciliationSourceIneligibleError(item.sourceId)); } resolvedPayments.push({ sourceId: item.sourceId, sourceType: "OUTGOING_PAYMENT", totalAmount: payment.totalAmount, paymentAccountId: payment.paymentAccountId, }); const effective = await getEffectiveClearedAmount(db, item.sourceType, item.sourceId); if (effective.plus(item.clearedAmount).gt(payment.totalAmount)) { return err(new BankReconciliationOverClearedError(item.sourceId)); } } paymentTotal = paymentTotal.plus(item.clearedAmount); } if (!statementTotal.eq(paymentTotal)) { return err(new BankReconciliationInvalidError(bankAccountId)); } const reconciliation = await db .insertInto("BankReconciliation") .values({ bankAccountId, reconciliationDate, confirmedAt: new Date(), action: "RECONCILE", }) .returningAll() .executeTakeFirstOrThrow(); const reconciliationItems = await db .insertInto("BankReconciliationItem") .values( items.map(({ sourceType, sourceId, clearedAmount }) => ({ bankReconciliationId: reconciliation.id, sourceType, sourceId, clearedAmount, })), ) .returningAll() .execute(); for (const statementLine of resolvedStatementLines) { await db .updateTable("BankStatementLine") .set({ reconciliationStatus: "RECONCILED", currentBankReconciliationId: reconciliation.id, }) .where("id", "=", statementLine.id) .execute(); } const paymentByKey = new Map( resolvedPayments.map((payment) => [`${payment.sourceType}:${payment.sourceId}`, payment]), ); let indirectTotal = new Decimal(0); for (const item of [...incomingItems, ...outgoingItems]) { const payment = paymentByKey.get(`${item.sourceType}:${item.sourceId}`); if (payment && payment.paymentAccountId !== bankAccount.cashAccountId) { indirectTotal = indirectTotal.plus(item.clearedAmount); } } let journalEntry = null; if (!indirectTotal.isZero()) { const clearingAccountId = incomingItems.length > 0 ? bankAccount.incomingClearingAccountId : bankAccount.outgoingClearingAccountId; if (!clearingAccountId) { return err(new BankReconciliationSourceIneligibleError(reconciliation.id)); } const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: bankAccount.companyId, date: reconciliationDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new BankReconciliationAccountingPeriodNotFoundError( `${bankAccount.companyId}:${reconciliationDate.toISOString()}`, ), ); } const incoming = incomingItems.length > 0; const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: bankAccount.companyId, accountingPeriodId: accountingPeriod.id, entryDate: reconciliationDate, description: "BANK RECONCILIATION", sourceDocumentType: "BANK_RECONCILIATION", sourceDocumentId: reconciliation.id, }, lines: incoming ? [ { accountId: bankAccount.cashAccountId, debitAmount: indirectTotal.toString(), creditAmount: null, description: "Bank account", }, { accountId: clearingAccountId, debitAmount: null, creditAmount: indirectTotal.toString(), description: "Incoming clearing", }, ] : [ { accountId: clearingAccountId, debitAmount: indirectTotal.toString(), creditAmount: null, description: "Outgoing clearing", }, { accountId: bankAccount.cashAccountId, debitAmount: null, creditAmount: indirectTotal.toString(), description: "Bank account", }, ], }, ctx, ); if (!createResult.ok) return err(new BankReconciliationJournalFailedError(reconciliation.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new BankReconciliationJournalFailedError(reconciliation.id)); journalEntry = postResult.value.journalEntry; } return ok({ reconciliation, reconciliationItems, journalEntry }); }