import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { outgoingPaymentLifecycle } from "../db/outgoingPayment.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { toSignedSettlementAmount } from "../internal/outgoingPaymentSettlementValidation"; import { OutgoingPaymentAccountingPeriodNotFoundError, OutgoingPaymentAlreadyReversedError, OutgoingPaymentInvalidStatusError, OutgoingPaymentNotFoundError, OutgoingPaymentReversalNotAllowedError, OutgoingPaymentJournalEntryCreateFailedError, OutgoingPaymentJournalEntryPostFailedError, } from "../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries } from "../module"; export interface ReverseOutgoingPaymentInput { id: string; reversalDate: Date; } /** Function: reverseOutgoingPayment * Reverses a posted payment in place while preserving append-only settlement and journal history. */ export async function run( db: Transaction, input: ReverseOutgoingPaymentInput, ctx: CommandContext, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const { id, reversalDate } = input; const original = await db .selectFrom("OutgoingPayment") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!original) return err(new OutgoingPaymentNotFoundError(id)); if (original.status === "REVERSED") { return err(new OutgoingPaymentAlreadyReversedError(id)); } const nextStatus = outgoingPaymentLifecycle.tryTransition(original.status, "reverse"); if (!nextStatus) { return err(new OutgoingPaymentInvalidStatusError(id)); } const originalSettlements = await db .selectFrom("AccountPayableSettlement as settlement") .innerJoin( "AccountPayableDueScheduleLine as dueSchedule", "dueSchedule.id", "settlement.accountPayableDueScheduleLineId", ) .innerJoin( "AccountPayableDocument as document", "document.id", "dueSchedule.accountPayableDocumentId", ) .selectAll("settlement") .select([ "dueSchedule.accountPayableDocumentId as accountPayableDocumentId", "document.documentType as documentType", "document.payableControlAccountId as payableControlAccountId", ]) .where("settlement.sourceType", "=", "OUTGOING_PAYMENT") .where("settlement.outgoingPaymentId", "=", original.id) .where("settlement.reversalOfSettlementId", "is", null) .orderBy("settlement.createdAt", "asc") .execute(); if (originalSettlements.length === 0) { return err(new OutgoingPaymentReversalNotAllowedError(id)); } const dueScheduleLineIds = [ ...new Set(originalSettlements.map((settlement) => settlement.accountPayableDueScheduleLineId)), ].sort(); await db .selectFrom("AccountPayableDueScheduleLine") .select("id") .where("id", "in", dueScheduleLineIds) .orderBy("id", "asc") .forUpdate() .execute(); await db .insertInto("AccountPayableSettlement") .values( originalSettlements.map((settlement) => { return { sourceType: "OUTGOING_PAYMENT" as const, outgoingPaymentId: original.id, accountPayableDueScheduleLineId: settlement.accountPayableDueScheduleLineId, settledAmount: settlement.settledAmount, reversalOfSettlementId: settlement.id, }; }), ) .returningAll() .execute(); const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: original.companyId, date: reversalDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new OutgoingPaymentAccountingPeriodNotFoundError( `${original.companyId}:${reversalDate.toISOString()}`, ), ); } const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: original.companyId, accountingPeriodId: accountingPeriod.id, entryDate: reversalDate, description: "OUTGOING PAYMENT REVERSAL", sourceDocumentType: "OUTGOING_PAYMENT", sourceDocumentId: original.id, }, lines: [ { accountId: original.paymentAccountId, debitAmount: original.totalAmount, creditAmount: null, description: "Outgoing payment reversal", }, ...originalSettlements.map((settlement) => toSignedSettlementAmount(settlement.documentType, settlement.settledAmount).isNegative() ? { accountId: settlement.payableControlAccountId, debitAmount: settlement.settledAmount, creditAmount: null, description: `Reverse settlement ${settlement.accountPayableDocumentId}`, } : { accountId: settlement.payableControlAccountId, debitAmount: null, creditAmount: settlement.settledAmount, description: `Reverse settlement ${settlement.accountPayableDocumentId}`, }, ), ], }, ctx, ); if (!createResult.ok) { return err(new OutgoingPaymentJournalEntryCreateFailedError(original.id)); } const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new OutgoingPaymentJournalEntryPostFailedError(original.id)); const reversedPayment = await db .updateTable("OutgoingPayment") .set({ status: nextStatus, reversalDate, reversedAt: new Date(), }) .where("id", "=", original.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ outgoingPayment: reversedPayment, journalEntry: postResult.value.journalEntry }); }