import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { incomingPaymentLifecycle } from "../db/incomingPayment.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { toSignedSettlementAmount } from "../internal/incomingPaymentSettlementValidation"; import { IncomingPaymentAccountingPeriodNotFoundError, IncomingPaymentAlreadyReversedError, IncomingPaymentInvalidStatusError, IncomingPaymentNotFoundError, IncomingPaymentReversalNotAllowedError, IncomingPaymentJournalEntryCreateFailedError, IncomingPaymentJournalEntryPostFailedError, } from "../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries } from "../module"; export interface ReverseIncomingPaymentInput { id: string; reversalDate: Date; } /** Function: reverseIncomingPayment * Reverses a posted payment in place while preserving append-only settlement and journal history. */ export async function run( db: Transaction, input: ReverseIncomingPaymentInput, ctx: CommandContext, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const { id, reversalDate } = input; const original = await db .selectFrom("IncomingPayment") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!original) return err(new IncomingPaymentNotFoundError(id)); if (original.status === "REVERSED") { return err(new IncomingPaymentAlreadyReversedError(id)); } const nextStatus = incomingPaymentLifecycle.tryTransition(original.status, "reverse"); if (!nextStatus) { return err(new IncomingPaymentInvalidStatusError(id)); } const originalSettlements = await db .selectFrom("AccountReceivableSettlement as settlement") .innerJoin( "AccountReceivableDueScheduleLine as dueSchedule", "dueSchedule.id", "settlement.accountReceivableDueScheduleLineId", ) .innerJoin( "AccountReceivableDocument as document", "document.id", "dueSchedule.accountReceivableDocumentId", ) .selectAll("settlement") .select([ "dueSchedule.accountReceivableDocumentId as accountReceivableDocumentId", "document.documentType as documentType", "document.receivableControlAccountId as receivableControlAccountId", ]) .where("settlement.sourceType", "=", "INCOMING_PAYMENT") .where("settlement.incomingPaymentId", "=", original.id) .where("settlement.reversalOfSettlementId", "is", null) .orderBy("settlement.createdAt", "asc") .execute(); if (originalSettlements.length === 0) { return err(new IncomingPaymentReversalNotAllowedError(id)); } const dueScheduleLineIds = [ ...new Set( originalSettlements.map((settlement) => settlement.accountReceivableDueScheduleLineId), ), ].sort(); await db .selectFrom("AccountReceivableDueScheduleLine") .select("id") .where("id", "in", dueScheduleLineIds) .orderBy("id", "asc") .forUpdate() .execute(); await db .insertInto("AccountReceivableSettlement") .values( originalSettlements.map((settlement) => { return { sourceType: "INCOMING_PAYMENT" as const, incomingPaymentId: original.id, accountReceivableDueScheduleLineId: settlement.accountReceivableDueScheduleLineId, 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 IncomingPaymentAccountingPeriodNotFoundError( `${original.companyId}:${reversalDate.toISOString()}`, ), ); } const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: original.companyId, accountingPeriodId: accountingPeriod.id, entryDate: reversalDate, description: "INCOMING PAYMENT REVERSAL", sourceDocumentType: "INCOMING_PAYMENT", sourceDocumentId: original.id, }, lines: [ { accountId: original.paymentAccountId, debitAmount: null, creditAmount: original.totalAmount, description: "Incoming payment reversal", }, ...originalSettlements.map((settlement) => toSignedSettlementAmount(settlement.documentType, settlement.settledAmount).isNegative() ? { accountId: settlement.receivableControlAccountId, debitAmount: null, creditAmount: settlement.settledAmount, description: `Reverse settlement ${settlement.accountReceivableDocumentId}`, } : { accountId: settlement.receivableControlAccountId, debitAmount: settlement.settledAmount, creditAmount: null, description: `Reverse settlement ${settlement.accountReceivableDocumentId}`, }, ), ], }, ctx, ); if (!createResult.ok) { return err(new IncomingPaymentJournalEntryCreateFailedError(original.id)); } const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new IncomingPaymentJournalEntryPostFailedError(original.id)); const reversedPayment = await db .updateTable("IncomingPayment") .set({ status: nextStatus, reversalDate, reversedAt: new Date(), }) .where("id", "=", original.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ incomingPayment: reversedPayment, journalEntry: postResult.value.journalEntry }); }