import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction } from "../generated/kysely-tailordb"; import { syncSalesOrderBillingStatusFromAccountReceivable } from "../internal/salesBillingFeedback"; import { AccountInactiveError, AccountNotFoundError, AccountingPeriodNotFoundError, CustomerAccountNotFoundError, InvalidCustomerAccountError, CurrencyNotFoundError, DistributionLineNotFoundError, DocumentLineNotFoundError, DocumentNotFoundError, DueScheduleInvalidError, InvalidAmountError, InvalidDocumentStatusError, JournalEntryCreateFailedError, JournalEntryPostFailedError, LineAmountMismatchError, LineSourceInconsistentError, LineTotalMismatchError, MinimumLinesNotMetError, PostingCurrencyMismatchError, CompanyNotFoundError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, FinancialAccountingCommands, FinancialAccountingQueries, OrganizationQueries, PrimitivesQueries, SalesCommands, } from "../module"; import type { AccountReceivableDistributionLineInput, AccountReceivableDocumentLineInput, AccountReceivableDocumentType, AccountReceivableDueScheduleLineInput, } from "./createAccountReceivableDocument"; export type AccountReceivableCorrectionType = "PRICE" | "QUANTITY" | "AMOUNT"; export interface AccountReceivableCorrectionDistributionLineInput extends AccountReceivableDistributionLineInput { correctionOfDistributionLineId?: string | null; } export interface CorrectAccountReceivableDocumentHeaderInput { receivableControlAccountId?: string | null; documentType: AccountReceivableDocumentType; externalDocumentNumber?: string | null; documentDate: Date; postingDate: Date; totalAmount: string; description?: string | null; } export interface AccountReceivableCorrectionLineInput extends AccountReceivableDocumentLineInput { correctionOfLineId: string; correctionType: AccountReceivableCorrectionType; distributions: AccountReceivableCorrectionDistributionLineInput[]; } export interface CorrectAccountReceivableDocumentInput { correctionOfId: string; header: CorrectAccountReceivableDocumentHeaderInput; lines: AccountReceivableCorrectionLineInput[]; dueSchedule: AccountReceivableDueScheduleLineInput[]; } function validatePositiveAmount(identifier: string, amount: string) { return new Decimal(amount).gt(0) ? ok({}) : err(new InvalidAmountError(identifier)); } function validateLineSource(identifier: string, line: AccountReceivableDocumentLineInput) { const isSalesOrder = line.sourceType === "SALES_ORDER"; const sourceFields = [line.salesOrderLineId, line.quantity, line.unitPrice, line.unitId]; if (isSalesOrder !== sourceFields.every((value) => value != null)) { return err(new LineSourceInconsistentError(identifier)); } if (!isSalesOrder) return ok({}); const quantity = new Decimal(line.quantity as string); const unitPrice = new Decimal(line.unitPrice as string); if ( !quantity.gt(0) || !unitPrice.gt(0) || !new Decimal(line.netAmount).eq(quantity.mul(unitPrice)) ) { return err(new LineAmountMismatchError(identifier)); } return ok({}); } export async function run( db: Transaction, input: Omit & { header: CorrectAccountReceivableDocumentHeaderInput & HCF; lines: (AccountReceivableCorrectionLineInput & LCF)[]; }, ctx: CommandContext, organizationQueries: Pick, customerAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, salesCommands: Pick, ) { const correctedDocument = await db .selectFrom("AccountReceivableDocument") .selectAll() .where("id", "=", input.correctionOfId) .forUpdate() .executeTakeFirst(); if (!correctedDocument) return err(new DocumentNotFoundError(input.correctionOfId)); if (correctedDocument.status !== "POSTED") { return err(new InvalidDocumentStatusError(input.correctionOfId)); } const correctedLines = await db .selectFrom("AccountReceivableDocumentLine") .selectAll() .where("accountReceivableDocumentId", "=", correctedDocument.id) .execute(); const correctedLineById = new Map(correctedLines.map((line) => [line.id, line])); const correctedDistributions = await db .selectFrom("AccountReceivableDistributionLine as distribution") .innerJoin( "AccountReceivableDocumentLine as line", "line.id", "distribution.accountReceivableDocumentLineId", ) .selectAll("distribution") .where("line.accountReceivableDocumentId", "=", correctedDocument.id) .execute(); const correctedDistributionById = new Map( correctedDistributions.map((distribution) => [distribution.id, distribution]), ); const correctionLines: (AccountReceivableCorrectionLineInput & LCF)[] = []; for (const line of input.lines) { const correctedLine = correctedLineById.get(line.correctionOfLineId); if (!correctedLine) return err(new DocumentLineNotFoundError(line.correctionOfLineId)); if (line.correctionType !== "AMOUNT" && correctedLine.sourceType !== "SALES_ORDER") { return err(new LineSourceInconsistentError(line.correctionOfLineId)); } if ( (line.sourceType != null && line.sourceType !== correctedLine.sourceType) || (line.salesOrderLineId != null && line.salesOrderLineId !== correctedLine.salesOrderLineId) || (line.unitId != null && line.unitId !== correctedLine.unitId) ) { return err(new LineSourceInconsistentError(line.correctionOfLineId)); } for (const distribution of line.distributions) { const correctedDistributionId = distribution.correctionOfDistributionLineId; if (!correctedDistributionId) continue; const correctedDistribution = correctedDistributionById.get(correctedDistributionId); if ( !correctedDistribution || correctedDistribution.accountReceivableDocumentLineId !== line.correctionOfLineId ) { return err(new DistributionLineNotFoundError(correctedDistributionId)); } } correctionLines.push({ ...line, sourceType: line.sourceType ?? correctedLine.sourceType, salesOrderLineId: line.salesOrderLineId ?? correctedLine.salesOrderLineId, unitId: line.unitId ?? correctedLine.unitId, distributions: line.distributions.map((distribution) => ({ ...distribution, correctionOfDistributionLineId: distribution.correctionOfDistributionLineId ?? null, })), }); } const { receivableControlAccountId: inputControlAccountId, documentType, externalDocumentNumber, documentDate, postingDate, totalAmount, description, ...headerCustomFields } = input.header; const receivableControlAccountId = inputControlAccountId ?? correctedDocument.receivableControlAccountId; const { company } = ( await organizationQueries.getCompany(db, { id: correctedDocument.companyId }, ctx) ).value; if (!company) return err(new CompanyNotFoundError(correctedDocument.companyId)); if (correctedDocument.currencyId !== company.baseCurrencyId) { return err(new PostingCurrencyMismatchError(input.correctionOfId)); } const { account: customerAccount } = ( await customerAccountQueries.getCustomerAccount( db, { customerAccountId: correctedDocument.customerAccountId }, ctx, ) ).value; if (!customerAccount) { return err(new CustomerAccountNotFoundError(correctedDocument.customerAccountId)); } if (customerAccount.companyId !== correctedDocument.companyId) return err(new InvalidCustomerAccountError(correctedDocument.customerAccountId)); const { currency } = ( await primitivesQueries.getCurrency(db, { id: correctedDocument.currencyId }, ctx) ).value; if (!currency) return err(new CurrencyNotFoundError(correctedDocument.currencyId)); if (correctionLines.length === 0) return err(new MinimumLinesNotMetError("AR correction")); const totalResult = validatePositiveAmount("AR correction", totalAmount); if (!totalResult.ok) return totalResult; for (const [index, line] of correctionLines.entries()) { const identifier = String(index + 1); if (!validatePositiveAmount(identifier, line.netAmount).ok) { return err(new InvalidAmountError(identifier)); } if (!validatePositiveAmount(identifier, line.grossAmount).ok) { return err(new InvalidAmountError(identifier)); } const sourceResult = validateLineSource(identifier, line); if (!sourceResult.ok) return sourceResult; const distributionTotal = line.distributions.reduce( (sum, distribution) => sum.plus(distribution.amount), new Decimal(0), ); if ( line.distributions.length === 0 || !distributionTotal.eq(line.grossAmount) || line.distributions.some((distribution) => new Decimal(distribution.amount).lte(0)) ) { return err(new LineTotalMismatchError(identifier)); } } const lineTotal = correctionLines.reduce( (sum, line) => sum.plus(line.grossAmount), new Decimal(0), ); if (!lineTotal.eq(totalAmount)) return err(new LineTotalMismatchError("AR correction")); const dueSchedule = input.dueSchedule ?? []; const dueTotal = dueSchedule.reduce( (sum, dueScheduleLine) => sum.plus(dueScheduleLine.amount), new Decimal(0), ); if ( dueSchedule.length === 0 || !dueTotal.eq(totalAmount) || dueSchedule.some((dueScheduleLine) => new Decimal(dueScheduleLine.amount).lte(0)) ) { return err(new DueScheduleInvalidError("AR correction")); } const accountIds = [ ...new Set([ receivableControlAccountId, ...correctionLines.flatMap((line) => line.distributions.map(({ accountId }) => accountId)), ]), ]; const accounts = ( await coaManagementQueries.listAccounts( db, { companyId: correctedDocument.companyId, accountIds, limit: accountIds.length }, ctx, ) ).value.items; const accountById = new Map(accounts.map((account) => [account.id, account])); for (const accountId of accountIds) { const account = accountById.get(accountId); if (!account) return err(new AccountNotFoundError(accountId)); if (account.status !== "ACTIVE") return err(new AccountInactiveError(accountId)); } const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: correctedDocument.companyId, date: postingDate }, ctx, ) ).value; if (!accountingPeriod) return err(new AccountingPeriodNotFoundError(input.correctionOfId)); const now = new Date(); const document = await db .insertInto("AccountReceivableDocument") .values({ ...(headerCustomFields as Record), companyId: correctedDocument.companyId, customerAccountId: correctedDocument.customerAccountId, currencyId: correctedDocument.currencyId, receivableControlAccountId, documentType, correctionOfId: correctedDocument.id, externalDocumentNumber: externalDocumentNumber ?? null, documentDate, postingDate, totalAmount, description: description ?? null, status: "POSTED", registeredAt: null, postedAt: now, cancelledAt: null, }) .returningAll() .executeTakeFirstOrThrow(); const lines = await db .insertInto("AccountReceivableDocumentLine") .values( correctionLines.map((line) => { const { netAmount, taxAmount, grossAmount, correctionOfLineId, correctionType, distributions: _distributions, description, sourceType, salesOrderLineId, quantity, unitPrice, unitId, ...lineCustomFields } = line; return { ...(lineCustomFields as Record), accountReceivableDocumentId: document.id, netAmount, taxAmount: taxAmount ?? null, grossAmount, correctionOfLineId, correctionType, sourceType: sourceType ?? null, salesOrderLineId: salesOrderLineId ?? null, quantity: quantity ?? null, unitPrice: unitPrice ?? null, unitId: unitId ?? null, description: description ?? null, }; }), ) .returningAll() .execute(); const distributions = correctionLines.flatMap((line, index) => { const insertedLine = lines[index]; return insertedLine ? line.distributions.map((distribution) => ({ accountReceivableDocumentLineId: insertedLine.id, correctionOfDistributionLineId: distribution.correctionOfDistributionLineId ?? null, accountId: distribution.accountId, amount: distribution.amount, description: distribution.description ?? null, })) : []; }); if (distributions.length === 0) return err(new DocumentLineNotFoundError("AR correction")); await db.insertInto("AccountReceivableDistributionLine").values(distributions).execute(); await db .insertInto("AccountReceivableDueScheduleLine") .values( dueSchedule.map((line) => ({ accountReceivableDocumentId: document.id, dueDate: line.dueDate, amount: line.amount, })), ) .execute(); const isInvoice = document.documentType === "INVOICE"; const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: document.companyId, accountingPeriodId: accountingPeriod.id, entryDate: document.postingDate, description: `${document.documentType} ${document.documentNumber}`, sourceDocumentType: "SALES_INVOICE", sourceDocumentId: document.id, }, lines: [ ...distributions.map((distribution) => ({ accountId: distribution.accountId, debitAmount: isInvoice ? null : distribution.amount, creditAmount: isInvoice ? distribution.amount : null, description: distribution.description, })), { accountId: document.receivableControlAccountId, debitAmount: isInvoice ? document.totalAmount : null, creditAmount: isInvoice ? null : document.totalAmount, description: "Accounts receivable control", }, ], }, ctx, ); if (!createResult.ok) return err(new JournalEntryCreateFailedError(document.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new JournalEntryPostFailedError(document.id)); const syncResult = await syncSalesOrderBillingStatusFromAccountReceivable( db, { document, lines }, ctx, salesCommands, ); if (!syncResult.ok) return syncResult; return ok({ accountReceivableDocument: document, journalEntry: postResult.value.journalEntry }); }