import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction } from "../generated/kysely-tailordb"; import { deriveReceiptDistributions } from "../internal/accrualDistribution"; import { postAcquisitionCostAdjustment } from "../internal/acquisitionCostAdjustment"; import { syncPurchaseOrderBillingStatusFromAccountPayable } from "../internal/purchaseBillingFeedback"; import { AccountInactiveError, AccountNotFoundError, ApAccountingPeriodNotFoundError, ApSupplierAccountNotFoundError, ApInvalidSupplierAccountError, ApCurrencyNotFoundError, ApDistributionLineNotFoundError, ApDocumentLineNotFoundError, ApDocumentNotFoundError, ApInvalidDocumentStatusError, ApInvalidAmountError, ApLineSourceInconsistentError, ApLineTotalMismatchError, ApMinimumLinesNotMetError, ApJournalEntryCreateFailedError, ApJournalEntryPostFailedError, ApPostingCurrencyMismatchError, CompanyNotFoundError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, FinancialAccountingCommands, FinancialAccountingQueries, InventoryCommands, InventoryQueries, OrganizationQueries, PrimitivesQueries, PurchaseCommands, PurchaseQueries, } from "../module"; import { type AccountPayableDistributionLineInput, type AccountPayableDocumentLineInput, type AccountPayableDocumentType, type AccountPayableDueScheduleLineInput, validateDueSchedule, validateLineSource, } from "./createAccountPayableDocument"; export type AccountPayableCorrectionType = "PRICE" | "QUANTITY" | "AMOUNT"; export interface AccountPayableCorrectionDistributionLineInput extends AccountPayableDistributionLineInput { correctionOfDistributionLineId?: string | null; } export interface CorrectAccountPayableDocumentHeaderInput { payableControlAccountId?: string | null; documentType: AccountPayableDocumentType; externalDocumentNumber?: string | null; documentDate: Date; postingDate: Date; totalAmount: string; description?: string | null; } export interface AccountPayableCorrectionLineInput extends AccountPayableDocumentLineInput { correctionOfLineId: string; correctionType: AccountPayableCorrectionType; distributions: AccountPayableCorrectionDistributionLineInput[]; } export interface CorrectAccountPayableDocumentInput { correctionOfId: string; header: CorrectAccountPayableDocumentHeaderInput; lines: AccountPayableCorrectionLineInput[]; dueSchedule: AccountPayableDueScheduleLineInput[]; } function validatePositiveAmount(identifier: string, amount: string) { if (new Decimal(amount).lte(0)) return err(new ApInvalidAmountError(identifier)); return ok({}); } function validateLineTotal( identifier: string, totalAmount: string, lines: { grossAmount: string }[], ) { const lineTotal = lines.reduce((sum, line) => sum.plus(line.grossAmount), new Decimal(0)); if (!lineTotal.eq(new Decimal(totalAmount))) { return err(new ApLineTotalMismatchError(identifier)); } return ok({}); } function validateDistributionTotal( identifier: string, totalAmount: string, distributions: { amount: string }[], ) { if (distributions.length === 0) return err(new ApMinimumLinesNotMetError(identifier)); const distributionTotal = distributions.reduce( (sum, distribution) => sum.plus(distribution.amount), new Decimal(0), ); if (!distributionTotal.eq(new Decimal(totalAmount))) { return err(new ApLineTotalMismatchError(identifier)); } return ok({}); } export async function run( db: Transaction, input: Omit & { header: CorrectAccountPayableDocumentHeaderInput & HCF; lines: (AccountPayableCorrectionLineInput & LCF)[]; }, ctx: CommandContext, organizationQueries: Pick, supplierAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, purchaseCommands: Pick, purchaseQueries: Pick, inventoryQueries: Pick, inventoryCommands: Pick, ) { const correctedDocument = await db .selectFrom("AccountPayableDocument") .selectAll() .where("id", "=", input.correctionOfId) .forUpdate() .executeTakeFirst(); if (!correctedDocument) return err(new ApDocumentNotFoundError(input.correctionOfId)); if (correctedDocument.status !== "POSTED") { return err(new ApInvalidDocumentStatusError(input.correctionOfId)); } const correctedLines = await db .selectFrom("AccountPayableDocumentLine") .selectAll() .where("accountPayableDocumentId", "=", input.correctionOfId) .execute(); const correctedLineById = new Map(correctedLines.map((line) => [line.id, line])); const correctedDistributions = await db .selectFrom("AccountPayableDistributionLine as distribution") .innerJoin( "AccountPayableDocumentLine as line", "line.id", "distribution.accountPayableDocumentLineId", ) .selectAll("distribution") .where("line.accountPayableDocumentId", "=", input.correctionOfId) .execute(); const correctedDistributionById = new Map( correctedDistributions.map((distribution) => [distribution.id, distribution]), ); const correctionLines: (AccountPayableCorrectionLineInput & LCF)[] = []; for (const line of input.lines) { const { correctionOfLineId, correctionType } = line; const correctedLine = correctedLineById.get(correctionOfLineId); if (!correctedLine) return err(new ApDocumentLineNotFoundError(correctionOfLineId)); if (correctionType !== "AMOUNT" && correctedLine.sourceType !== "PURCHASE_ORDER") { return err(new ApLineSourceInconsistentError(correctionOfLineId)); } if ( (line.sourceType != null && line.sourceType !== correctedLine.sourceType) || (line.purchaseOrderLineId != null && line.purchaseOrderLineId !== correctedLine.purchaseOrderLineId) || (line.unitId != null && line.unitId !== correctedLine.unitId) ) { return err(new ApLineSourceInconsistentError(correctionOfLineId)); } for (const distribution of line.distributions) { const correctedDistributionId = distribution.correctionOfDistributionLineId; if (!correctedDistributionId) continue; const correctedDistribution = correctedDistributionById.get(correctedDistributionId); if ( !correctedDistribution || correctedDistribution.accountPayableDocumentLineId !== correctionOfLineId ) { return err(new ApDistributionLineNotFoundError(correctedDistributionId)); } } correctionLines.push({ ...line, sourceType: line.sourceType ?? correctedLine.sourceType, purchaseOrderLineId: line.purchaseOrderLineId ?? correctedLine.purchaseOrderLineId, unitId: line.unitId ?? correctedLine.unitId, distributions: line.distributions.map((distribution) => ({ ...distribution, correctionOfDistributionLineId: distribution.correctionOfDistributionLineId ?? null, })), }); } const { payableControlAccountId: inputPayableControlAccountId, documentType, externalDocumentNumber, documentDate, postingDate, totalAmount, description, ...headerCustomFields } = input.header; const payableControlAccountId = inputPayableControlAccountId ?? correctedDocument.payableControlAccountId; 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 ApPostingCurrencyMismatchError(input.correctionOfId)); } const { account: supplierAccount } = ( await supplierAccountQueries.getSupplierAccount( db, { supplierAccountId: correctedDocument.supplierAccountId }, ctx, ) ).value; if (!supplierAccount) { return err(new ApSupplierAccountNotFoundError(correctedDocument.supplierAccountId)); } if (supplierAccount.companyId !== correctedDocument.companyId) return err(new ApInvalidSupplierAccountError(correctedDocument.supplierAccountId)); const { currency } = ( await primitivesQueries.getCurrency(db, { id: correctedDocument.currencyId }, ctx) ).value; if (!currency) return err(new ApCurrencyNotFoundError(correctedDocument.currencyId)); if (correctionLines.length === 0) return err(new ApMinimumLinesNotMetError("AP correction")); const totalResult = validatePositiveAmount("AP correction", totalAmount); if (!totalResult.ok) return totalResult; for (const [index, line] of correctionLines.entries()) { const identifier = String(index + 1); const netAmountResult = validatePositiveAmount(identifier, line.netAmount); if (!netAmountResult.ok) return netAmountResult; const grossAmountResult = validatePositiveAmount(identifier, line.grossAmount); if (!grossAmountResult.ok) return grossAmountResult; const lineSourceResult = validateLineSource(identifier, line); if (!lineSourceResult.ok) return lineSourceResult; } const lineTotalResult = validateLineTotal("AP correction", totalAmount, correctionLines); if (!lineTotalResult.ok) return lineTotalResult; const dueScheduleResult = validateDueSchedule( "AP correction", totalAmount, input.dueSchedule ?? [], { requireLines: true }, ); if (!dueScheduleResult.ok) return dueScheduleResult; const derivedResult = await deriveReceiptDistributions( db, { companyId: correctedDocument.companyId, supplierAccountId: correctedDocument.supplierAccountId, lines: correctionLines, }, ctx, purchaseQueries, inventoryQueries, ); if (!derivedResult.ok) return derivedResult; const derivedByIndex = derivedResult.value; const effectiveDistributions: (AccountPayableCorrectionDistributionLineInput & { distributionType?: "ACCRUAL" | "INVOICE_PRICE_VARIANCE"; })[][] = correctionLines.map((line, index) => [ ...(derivedByIndex.get(index) ?? []), ...line.distributions, ]); const accountIds = [ ...new Set([ payableControlAccountId, ...effectiveDistributions.flatMap((distributions) => distributions.map((distribution) => distribution.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)); } for (const [index, line] of correctionLines.entries()) { const distributions = effectiveDistributions[index] ?? []; const distributionTotalResult = validateDistributionTotal( String(index + 1), line.grossAmount, distributions, ); if (!distributionTotalResult.ok) return distributionTotalResult; for (const distribution of distributions) { // The derived invoice price variance row is signed. if (distribution.distributionType !== undefined) continue; const amountResult = validatePositiveAmount(distribution.accountId, distribution.amount); if (!amountResult.ok) return amountResult; } } const { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: correctedDocument.companyId, date: postingDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new ApAccountingPeriodNotFoundError( `${correctedDocument.companyId}:${postingDate.toISOString()}`, ), ); } // Corrections never pass through registration, so creation is where their // price basis is fixed. const correctionPurchaseOrderLineIds = [ ...new Set( correctionLines .map((line) => line.purchaseOrderLineId) .filter((id): id is string => id != null), ), ]; const matchedUnitPriceByPurchaseOrderLineId = new Map(); if (correctionPurchaseOrderLineIds.length > 0) { const { purchaseOrderLines } = ( await purchaseQueries.listPurchaseOrderLinesForMatching( db, { purchaseOrderLineIds: correctionPurchaseOrderLineIds }, ctx, ) ).value; for (const purchaseOrderLine of purchaseOrderLines) { matchedUnitPriceByPurchaseOrderLineId.set(purchaseOrderLine.id, purchaseOrderLine.unitPrice); } } const now = new Date(); const document = await db .insertInto("AccountPayableDocument") .values({ ...(headerCustomFields as Record), companyId: correctedDocument.companyId, supplierAccountId: correctedDocument.supplierAccountId, currencyId: correctedDocument.currencyId, payableControlAccountId, 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("AccountPayableDocumentLine") .values( correctionLines.map((line) => { const { netAmount, taxAmount, grossAmount, correctionOfLineId, correctionType, distributions: _distributions, description, sourceType, purchaseOrderLineId, quantity, unitPrice, unitId, ...lineCustomFields } = line; return { ...(lineCustomFields as Record), accountPayableDocumentId: document.id, netAmount, taxAmount: taxAmount ?? null, grossAmount, correctionOfLineId, correctionType, description: description ?? null, sourceType: sourceType ?? null, purchaseOrderLineId: purchaseOrderLineId ?? null, quantity: quantity ?? null, unitPrice: unitPrice ?? null, matchedPurchaseOrderUnitPrice: purchaseOrderLineId != null ? (matchedUnitPriceByPurchaseOrderLineId.get(purchaseOrderLineId) ?? null) : null, unitId: unitId ?? null, }; }), ) .returningAll() .execute(); const distributions = []; for (const index of correctionLines.keys()) { const insertedLine = lines[index]; if (!insertedLine) return err(new ApDocumentLineNotFoundError(String(index + 1))); distributions.push( ...(effectiveDistributions[index] ?? []).map((distribution) => ({ accountPayableDocumentLineId: insertedLine.id, accountId: distribution.accountId, amount: distribution.amount, distributionType: distribution.distributionType ?? ("MANUAL" as const), correctionOfDistributionLineId: distribution.correctionOfDistributionLineId ?? null, description: distribution.description ?? null, })), ); } await db.insertInto("AccountPayableDistributionLine").values(distributions).execute(); await db .insertInto("AccountPayableDueScheduleLine") .values( input.dueSchedule.map((line) => ({ accountPayableDocumentId: 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: "ACCOUNT_PAYABLE_DOCUMENT", sourceDocumentId: document.id, }, lines: [ // A negative derived amount (invoice price variance billed below the // order price) flips to the opposite side. ...distributions.map((distribution) => { const amount = new Decimal(distribution.amount); const debit = isInvoice === !amount.isNegative(); return { accountId: distribution.accountId, debitAmount: debit ? amount.abs().toString() : null, creditAmount: debit ? null : amount.abs().toString(), description: distribution.description, }; }), { accountId: document.payableControlAccountId, debitAmount: isInvoice ? null : document.totalAmount, creditAmount: isInvoice ? document.totalAmount : null, description: "Accounts payable control", }, ], }, ctx, ); if (!createResult.ok) return err(new ApJournalEntryCreateFailedError(document.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new ApJournalEntryPostFailedError(document.id)); const adjustmentResult = await postAcquisitionCostAdjustment( db, { document, lines }, ctx, purchaseQueries, inventoryCommands, ); if (!adjustmentResult.ok) return adjustmentResult; const billingSyncResult = await syncPurchaseOrderBillingStatusFromAccountPayable( db, { document, lines }, ctx, purchaseCommands, ); if (!billingSyncResult.ok) return billingSyncResult; return ok({ accountPayableDocument: document, journalEntry: postResult.value.journalEntry, }); }