import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { incomingPaymentLifecycle } from "../db/incomingPayment.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { isIncomingPaymentSettlementTargetEligible, toSignedSettlementAmount, } from "../internal/incomingPaymentSettlementValidation"; import { AccountCompanyMismatchError, AccountInactiveError, AccountNotFoundError, IncomingPaymentAccountingPeriodNotFoundError, IncomingPaymentJournalEntryCreateFailedError, IncomingPaymentJournalEntryPostFailedError, CustomerAccountNotFoundError, CurrencyNotFoundError, InvalidCustomerAccountError, CompanyInactiveError, CompanyNotFoundError, IncomingPaymentInvalidAmountError, IncomingPaymentInvalidStatusError, IncomingPaymentNotFoundError, IncomingPaymentOverSettlementError, IncomingPaymentSettlementInvalidError, IncomingPaymentTargetIneligibleError, IncomingPaymentTotalMismatchError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, FinancialAccountingCommands, FinancialAccountingQueries, OrganizationQueries, PrimitivesQueries, } from "../module"; export interface PostIncomingPaymentInput { id: string; } /** Function: postIncomingPayment * Makes validated settlement allocations effective and posts a journal entry. */ export async function run( db: Transaction, input: PostIncomingPaymentInput, ctx: CommandContext, organizationQueries: Pick, customerAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const incomingPayment = await db .selectFrom("IncomingPayment") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!incomingPayment) return err(new IncomingPaymentNotFoundError(input.id)); const nextStatus = incomingPaymentLifecycle.tryTransition(incomingPayment.status, "post"); if (!nextStatus) return err(new IncomingPaymentInvalidStatusError(input.id)); if (new Decimal(incomingPayment.totalAmount).lte(0)) { return err(new IncomingPaymentInvalidAmountError(input.id)); } const { company } = ( await organizationQueries.getCompany(db, { id: incomingPayment.companyId }, ctx) ).value; if (!company) return err(new CompanyNotFoundError(incomingPayment.companyId)); if (company.status !== "ACTIVE") { return err(new CompanyInactiveError(incomingPayment.companyId)); } const { account: customerAccount } = ( await customerAccountQueries.getCustomerAccount( db, { customerAccountId: incomingPayment.customerAccountId }, ctx, ) ).value; if (!customerAccount) { return err(new CustomerAccountNotFoundError(incomingPayment.customerAccountId)); } if ( customerAccount.accountStatus !== "ACTIVE" || customerAccount.companyId !== incomingPayment.companyId ) { return err(new InvalidCustomerAccountError(incomingPayment.customerAccountId)); } const { currency } = ( await primitivesQueries.getCurrency(db, { id: incomingPayment.currencyId }, ctx) ).value; if (!currency) return err(new CurrencyNotFoundError(incomingPayment.currencyId)); const { account } = ( await coaManagementQueries.getAccount(db, { id: incomingPayment.paymentAccountId }, ctx) ).value; if (!account) return err(new AccountNotFoundError(incomingPayment.paymentAccountId)); if (account.companyId !== incomingPayment.companyId) { return err(new AccountCompanyMismatchError(incomingPayment.paymentAccountId)); } if (account.status !== "ACTIVE") { return err(new AccountInactiveError(incomingPayment.paymentAccountId)); } const settlements = await db .selectFrom("AccountReceivableSettlement") .selectAll() .where("sourceType", "=", "INCOMING_PAYMENT") .where("incomingPaymentId", "=", input.id) .orderBy("id", "asc") .forUpdate() .execute(); if (settlements.length === 0) { return err(new IncomingPaymentSettlementInvalidError(input.id)); } const dueScheduleLineIds = settlements .map((settlement) => settlement.accountReceivableDueScheduleLineId) .sort(); if (new Set(dueScheduleLineIds).size !== dueScheduleLineIds.length) { return err(new IncomingPaymentSettlementInvalidError(input.id)); } for (const settlement of settlements) { const amount = new Decimal(settlement.settledAmount); if (amount.lte(0)) { return err(new IncomingPaymentInvalidAmountError(settlement.id)); } } // Lock due-schedule rows in deterministic order. Any other payment posting // against the same obligation must wait before reading effective settlements. const dueSchedules = await db .selectFrom("AccountReceivableDueScheduleLine") .selectAll() .where("id", "in", dueScheduleLineIds) .orderBy("id", "asc") .forUpdate() .execute(); const dueScheduleById = new Map(dueSchedules.map((line) => [line.id, line])); for (const dueScheduleLineId of dueScheduleLineIds) { if (!dueScheduleById.has(dueScheduleLineId)) { return err(new IncomingPaymentTargetIneligibleError(dueScheduleLineId)); } } const accountReceivableDocumentIds = [ ...new Set(dueSchedules.map((line) => line.accountReceivableDocumentId)), ]; const accountReceivableDocuments = await db .selectFrom("AccountReceivableDocument") .selectAll() .where("id", "in", accountReceivableDocumentIds) .orderBy("id", "asc") .forUpdate() .execute(); const accountReceivableDocumentById = new Map( accountReceivableDocuments.map((document) => [document.id, document]), ); for (const dueSchedule of dueSchedules) { const document = accountReceivableDocumentById.get(dueSchedule.accountReceivableDocumentId); if (!document || !isIncomingPaymentSettlementTargetEligible(document, incomingPayment)) { return err(new IncomingPaymentTargetIneligibleError(dueSchedule.id)); } } let signedSettlementTotal = new Decimal(0); for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountReceivableDueScheduleLineId); if (!dueSchedule) { return err( new IncomingPaymentTargetIneligibleError(settlement.accountReceivableDueScheduleLineId), ); } const document = accountReceivableDocumentById.get(dueSchedule.accountReceivableDocumentId); if (!document) { return err(new IncomingPaymentTargetIneligibleError(dueSchedule.id)); } signedSettlementTotal = signedSettlementTotal.plus( toSignedSettlementAmount(document.documentType, settlement.settledAmount), ); } if (!signedSettlementTotal.eq(new Decimal(incomingPayment.totalAmount))) { return err(new IncomingPaymentTotalMismatchError(input.id)); } const postedSettlements = await db .selectFrom("AccountReceivableSettlement as postedSettlement") .innerJoin( "IncomingPayment as postedPayment", "postedPayment.id", "postedSettlement.incomingPaymentId", ) .select([ "postedSettlement.accountReceivableDueScheduleLineId", "postedSettlement.settledAmount", "postedSettlement.reversalOfSettlementId", ]) .where("postedSettlement.accountReceivableDueScheduleLineId", "in", dueScheduleLineIds) .where("postedSettlement.sourceType", "=", "INCOMING_PAYMENT") .where("postedPayment.status", "in", ["POSTED", "REVERSED"]) .execute(); const effectiveSettledByDueScheduleId = new Map(); for (const postedSettlement of postedSettlements) { const current = effectiveSettledByDueScheduleId.get(postedSettlement.accountReceivableDueScheduleLineId) ?? new Decimal(0); const amount = new Decimal(postedSettlement.settledAmount); effectiveSettledByDueScheduleId.set( postedSettlement.accountReceivableDueScheduleLineId, postedSettlement.reversalOfSettlementId ? current.minus(amount) : current.plus(amount), ); } for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountReceivableDueScheduleLineId); if (!dueSchedule) { return err( new IncomingPaymentTargetIneligibleError(settlement.accountReceivableDueScheduleLineId), ); } const alreadySettled = effectiveSettledByDueScheduleId.get(settlement.accountReceivableDueScheduleLineId) ?? new Decimal(0); if (alreadySettled.plus(settlement.settledAmount).gt(dueSchedule.amount)) { return err( new IncomingPaymentOverSettlementError(settlement.accountReceivableDueScheduleLineId), ); } } const journalLines = []; for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountReceivableDueScheduleLineId); if (!dueSchedule) { return err( new IncomingPaymentTargetIneligibleError(settlement.accountReceivableDueScheduleLineId), ); } const document = accountReceivableDocumentById.get(dueSchedule.accountReceivableDocumentId); if (!document) { return err(new IncomingPaymentTargetIneligibleError(dueSchedule.id)); } const signedSettlementAmount = toSignedSettlementAmount( document.documentType, settlement.settledAmount, ); journalLines.push( signedSettlementAmount.isNegative() ? { accountId: document.receivableControlAccountId, debitAmount: settlement.settledAmount, creditAmount: null, description: `Settle ${document.documentNumber}`, } : { accountId: document.receivableControlAccountId, debitAmount: null, creditAmount: settlement.settledAmount, description: `Settle ${document.documentNumber}`, }, ); } const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: incomingPayment.companyId, date: incomingPayment.paymentDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new IncomingPaymentAccountingPeriodNotFoundError( `${incomingPayment.companyId}:${incomingPayment.paymentDate.toISOString()}`, ), ); } const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: incomingPayment.companyId, accountingPeriodId: accountingPeriod.id, entryDate: incomingPayment.paymentDate, description: "INCOMING PAYMENT", sourceDocumentType: "INCOMING_PAYMENT", sourceDocumentId: incomingPayment.id, }, lines: [ ...journalLines, { accountId: incomingPayment.paymentAccountId, debitAmount: incomingPayment.totalAmount, creditAmount: null, description: "Incoming payment account", }, ], }, ctx, ); if (!createResult.ok) return err(new IncomingPaymentJournalEntryCreateFailedError(input.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new IncomingPaymentJournalEntryPostFailedError(input.id)); const postedIncomingPayment = await db .updateTable("IncomingPayment") .set({ status: nextStatus, postedAt: new Date() }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ incomingPayment: postedIncomingPayment, journalEntry: postResult.value.journalEntry, }); }