import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { outgoingPaymentLifecycle } from "../db/outgoingPayment.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { isOutgoingPaymentSettlementTargetEligible, toSignedSettlementAmount, } from "../internal/outgoingPaymentSettlementValidation"; import { AccountCompanyMismatchError, AccountInactiveError, AccountNotFoundError, OutgoingPaymentAccountingPeriodNotFoundError, OutgoingPaymentJournalEntryCreateFailedError, OutgoingPaymentJournalEntryPostFailedError, ApSupplierAccountNotFoundError, ApCurrencyNotFoundError, ApInvalidSupplierAccountError, CompanyInactiveError, CompanyNotFoundError, OutgoingPaymentInvalidAmountError, OutgoingPaymentInvalidStatusError, OutgoingPaymentNotFoundError, OutgoingPaymentOverSettlementError, OutgoingPaymentSettlementInvalidError, OutgoingPaymentTargetIneligibleError, OutgoingPaymentTotalMismatchError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, FinancialAccountingCommands, FinancialAccountingQueries, OrganizationQueries, PrimitivesQueries, } from "../module"; export interface PostOutgoingPaymentInput { id: string; } /** Function: postOutgoingPayment * Makes validated settlement allocations effective and posts a journal entry. */ export async function run( db: Transaction, input: PostOutgoingPaymentInput, ctx: CommandContext, organizationQueries: Pick, supplierAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const outgoingPayment = await db .selectFrom("OutgoingPayment") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!outgoingPayment) return err(new OutgoingPaymentNotFoundError(input.id)); const nextStatus = outgoingPaymentLifecycle.tryTransition(outgoingPayment.status, "post"); if (!nextStatus) return err(new OutgoingPaymentInvalidStatusError(input.id)); if (new Decimal(outgoingPayment.totalAmount).lte(0)) { return err(new OutgoingPaymentInvalidAmountError(input.id)); } const { company } = ( await organizationQueries.getCompany(db, { id: outgoingPayment.companyId }, ctx) ).value; if (!company) return err(new CompanyNotFoundError(outgoingPayment.companyId)); if (company.status !== "ACTIVE") { return err(new CompanyInactiveError(outgoingPayment.companyId)); } const { account: supplierAccount } = ( await supplierAccountQueries.getSupplierAccount( db, { supplierAccountId: outgoingPayment.supplierAccountId }, ctx, ) ).value; if (!supplierAccount) { return err(new ApSupplierAccountNotFoundError(outgoingPayment.supplierAccountId)); } if ( supplierAccount.accountStatus !== "ACTIVE" || supplierAccount.companyId !== outgoingPayment.companyId ) { return err(new ApInvalidSupplierAccountError(outgoingPayment.supplierAccountId)); } const { currency } = ( await primitivesQueries.getCurrency(db, { id: outgoingPayment.currencyId }, ctx) ).value; if (!currency) return err(new ApCurrencyNotFoundError(outgoingPayment.currencyId)); const { account } = ( await coaManagementQueries.getAccount(db, { id: outgoingPayment.paymentAccountId }, ctx) ).value; if (!account) return err(new AccountNotFoundError(outgoingPayment.paymentAccountId)); if (account.companyId !== outgoingPayment.companyId) { return err(new AccountCompanyMismatchError(outgoingPayment.paymentAccountId)); } if (account.status !== "ACTIVE") { return err(new AccountInactiveError(outgoingPayment.paymentAccountId)); } const settlements = await db .selectFrom("AccountPayableSettlement") .selectAll() .where("sourceType", "=", "OUTGOING_PAYMENT") .where("outgoingPaymentId", "=", input.id) .orderBy("id", "asc") .forUpdate() .execute(); if (settlements.length === 0) { return err(new OutgoingPaymentSettlementInvalidError(input.id)); } const dueScheduleLineIds = settlements .map((settlement) => settlement.accountPayableDueScheduleLineId) .sort(); if (new Set(dueScheduleLineIds).size !== dueScheduleLineIds.length) { return err(new OutgoingPaymentSettlementInvalidError(input.id)); } for (const settlement of settlements) { const amount = new Decimal(settlement.settledAmount); if (amount.lte(0)) { return err(new OutgoingPaymentInvalidAmountError(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("AccountPayableDueScheduleLine") .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 OutgoingPaymentTargetIneligibleError(dueScheduleLineId)); } } const accountPayableDocumentIds = [ ...new Set(dueSchedules.map((line) => line.accountPayableDocumentId)), ]; const accountPayableDocuments = await db .selectFrom("AccountPayableDocument") .selectAll() .where("id", "in", accountPayableDocumentIds) .orderBy("id", "asc") .forUpdate() .execute(); const accountPayableDocumentById = new Map( accountPayableDocuments.map((document) => [document.id, document]), ); for (const dueSchedule of dueSchedules) { const document = accountPayableDocumentById.get(dueSchedule.accountPayableDocumentId); if (!document || !isOutgoingPaymentSettlementTargetEligible(document, outgoingPayment)) { return err(new OutgoingPaymentTargetIneligibleError(dueSchedule.id)); } } let signedSettlementTotal = new Decimal(0); for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountPayableDueScheduleLineId); if (!dueSchedule) { return err( new OutgoingPaymentTargetIneligibleError(settlement.accountPayableDueScheduleLineId), ); } const document = accountPayableDocumentById.get(dueSchedule.accountPayableDocumentId); if (!document) { return err(new OutgoingPaymentTargetIneligibleError(dueSchedule.id)); } signedSettlementTotal = signedSettlementTotal.plus( toSignedSettlementAmount(document.documentType, settlement.settledAmount), ); } if (!signedSettlementTotal.eq(new Decimal(outgoingPayment.totalAmount))) { return err(new OutgoingPaymentTotalMismatchError(input.id)); } const postedSettlements = await db .selectFrom("AccountPayableSettlement as postedSettlement") .innerJoin( "OutgoingPayment as postedPayment", "postedPayment.id", "postedSettlement.outgoingPaymentId", ) .select([ "postedSettlement.accountPayableDueScheduleLineId", "postedSettlement.settledAmount", "postedSettlement.reversalOfSettlementId", ]) .where("postedSettlement.accountPayableDueScheduleLineId", "in", dueScheduleLineIds) .where("postedSettlement.sourceType", "=", "OUTGOING_PAYMENT") .where("postedPayment.status", "in", ["POSTED", "REVERSED"]) .execute(); const effectiveSettledByDueScheduleId = new Map(); for (const postedSettlement of postedSettlements) { const current = effectiveSettledByDueScheduleId.get(postedSettlement.accountPayableDueScheduleLineId) ?? new Decimal(0); const amount = new Decimal(postedSettlement.settledAmount); effectiveSettledByDueScheduleId.set( postedSettlement.accountPayableDueScheduleLineId, postedSettlement.reversalOfSettlementId ? current.minus(amount) : current.plus(amount), ); } for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountPayableDueScheduleLineId); if (!dueSchedule) { return err( new OutgoingPaymentTargetIneligibleError(settlement.accountPayableDueScheduleLineId), ); } const alreadySettled = effectiveSettledByDueScheduleId.get(settlement.accountPayableDueScheduleLineId) ?? new Decimal(0); if (alreadySettled.plus(settlement.settledAmount).gt(dueSchedule.amount)) { return err( new OutgoingPaymentOverSettlementError(settlement.accountPayableDueScheduleLineId), ); } } const journalLines = []; for (const settlement of settlements) { const dueSchedule = dueScheduleById.get(settlement.accountPayableDueScheduleLineId); if (!dueSchedule) { return err( new OutgoingPaymentTargetIneligibleError(settlement.accountPayableDueScheduleLineId), ); } const document = accountPayableDocumentById.get(dueSchedule.accountPayableDocumentId); if (!document) { return err(new OutgoingPaymentTargetIneligibleError(dueSchedule.id)); } const signedSettlementAmount = toSignedSettlementAmount( document.documentType, settlement.settledAmount, ); journalLines.push( signedSettlementAmount.isNegative() ? { accountId: document.payableControlAccountId, debitAmount: null, creditAmount: settlement.settledAmount, description: `Settle ${document.documentNumber}`, } : { accountId: document.payableControlAccountId, debitAmount: settlement.settledAmount, creditAmount: null, description: `Settle ${document.documentNumber}`, }, ); } const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: outgoingPayment.companyId, date: outgoingPayment.paymentDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new OutgoingPaymentAccountingPeriodNotFoundError( `${outgoingPayment.companyId}:${outgoingPayment.paymentDate.toISOString()}`, ), ); } const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: outgoingPayment.companyId, accountingPeriodId: accountingPeriod.id, entryDate: outgoingPayment.paymentDate, description: "OUTGOING PAYMENT", sourceDocumentType: "OUTGOING_PAYMENT", sourceDocumentId: outgoingPayment.id, }, lines: [ ...journalLines, { accountId: outgoingPayment.paymentAccountId, debitAmount: null, creditAmount: outgoingPayment.totalAmount, description: "Outgoing payment account", }, ], }, ctx, ); if (!createResult.ok) return err(new OutgoingPaymentJournalEntryCreateFailedError(input.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new OutgoingPaymentJournalEntryPostFailedError(input.id)); const postedOutgoingPayment = await db .updateTable("OutgoingPayment") .set({ status: nextStatus, postedAt: new Date() }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ outgoingPayment: postedOutgoingPayment, journalEntry: postResult.value.journalEntry, }); }