import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { accountPayableDocumentLifecycle } from "../db/accountPayableDocument.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { deriveReceiptDistributions } from "../internal/accrualDistribution"; import { type AccountPayableToleranceRule, type AlreadyInvoicedQuantity, planAccountPayableSystemHolds, } from "../internal/holdReconciliation"; import { syncPurchaseOrderBillingStatusFromAccountPayable } from "../internal/purchaseBillingFeedback"; import { ApDocumentNotFoundError, ApDueScheduleInvalidError, ApInvalidDocumentStatusError, ApLineTotalMismatchError, ApMinimumLinesNotMetError, } from "../lib/errors.generated"; import type { InventoryQueries, PurchaseCommands, PurchaseQueries } from "../module"; import { validateDueSchedule } from "./createAccountPayableDocument"; export interface RegisterAccountPayableDocumentInput { id: string; } function validateLineTotal( identifier: string, totalAmount: string, lines: { grossAmount: string }[], ) { if (lines.length === 0) return err(new ApMinimumLinesNotMetError(identifier)); 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({}); } async function loadAlreadyInvoicedQuantities( db: Transaction, accountPayableDocumentId: string, purchaseOrderLineIds: string[], ): Promise { if (purchaseOrderLineIds.length === 0) return []; const rows = await db .selectFrom("AccountPayableDocumentLine as line") .innerJoin("AccountPayableDocument as document", "document.id", "line.accountPayableDocumentId") .select([ "line.purchaseOrderLineId as purchaseOrderLineId", "line.quantity as quantity", "line.correctionType as correctionType", "document.documentType as documentType", ]) .where("line.purchaseOrderLineId", "in", purchaseOrderLineIds) .where("document.id", "!=", accountPayableDocumentId) .where("document.status", "in", ["REGISTERED", "POSTED"]) .execute(); const quantityByLineId = new Map(); for (const row of rows) { if (row.purchaseOrderLineId === null || row.quantity === null) continue; if (row.correctionType === "PRICE" || row.correctionType === "AMOUNT") continue; const signedQuantity = row.documentType === "CREDIT_MEMO" ? new Decimal(row.quantity).neg() : new Decimal(row.quantity); quantityByLineId.set( row.purchaseOrderLineId, (quantityByLineId.get(row.purchaseOrderLineId) ?? new Decimal(0)).plus(signedQuantity), ); } return [...quantityByLineId.entries()].map(([purchaseOrderLineId, quantity]) => ({ purchaseOrderLineId, quantity: quantity.toString(), })); } async function loadSupplierInvoiceTolerance( db: Transaction, companyId: string, supplierAccountId: string, ): Promise { const config = await db .selectFrom("InvoiceToleranceConfig") .selectAll() .where("companyId", "=", companyId) .where("supplierAccountId", "=", supplierAccountId) .executeTakeFirst(); if (!config) return undefined; return { quantity: { absolute: config.quantityAbsoluteTolerance ?? undefined, percent: config.quantityPercentageTolerance ?? undefined, }, unitPrice: { absolute: config.unitPriceAbsoluteTolerance ?? undefined, percent: config.unitPricePercentageTolerance ?? undefined, }, }; } export async function run( db: Transaction, input: RegisterAccountPayableDocumentInput, ctx: CommandContext, purchaseQueries: Pick, purchaseCommands: Pick, inventoryQueries: Pick, ) { const document = await db .selectFrom("AccountPayableDocument") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!document) return err(new ApDocumentNotFoundError(input.id)); const nextStatus = accountPayableDocumentLifecycle.tryTransition(document.status, "register"); if (!nextStatus) return err(new ApInvalidDocumentStatusError(input.id)); const lines = await db .selectFrom("AccountPayableDocumentLine") .selectAll() .where("accountPayableDocumentId", "=", input.id) .execute(); const lineTotalResult = validateLineTotal(input.id, document.totalAmount, lines); if (!lineTotalResult.ok) return lineTotalResult; const distributions = await db .selectFrom("AccountPayableDistributionLine as distribution") .innerJoin( "AccountPayableDocumentLine as line", "line.id", "distribution.accountPayableDocumentLineId", ) .selectAll("distribution") .where("line.accountPayableDocumentId", "=", input.id) .execute(); const distributionTotalResult = validateDistributionTotal( input.id, document.totalAmount, distributions, ); if (!distributionTotalResult.ok) return distributionTotalResult; const dueSchedule = await db .selectFrom("AccountPayableDueScheduleLine") .selectAll() .where("accountPayableDocumentId", "=", input.id) .execute(); if (dueSchedule.length === 0) return err(new ApDueScheduleInvalidError(input.id)); const dueScheduleResult = validateDueSchedule(input.id, document.totalAmount, dueSchedule, { requireLines: true, }); if (!dueScheduleResult.ok) return dueScheduleResult; const tolerance = await loadSupplierInvoiceTolerance( db, document.companyId, document.supplierAccountId, ); const purchaseOrderLineIds = [ ...new Set(lines.map((line) => line.purchaseOrderLineId).filter((id): id is string => !!id)), ]; const alreadyInvoicedQuantities = await loadAlreadyInvoicedQuantities( db, input.id, purchaseOrderLineIds, ); const purchaseOrderLines = purchaseOrderLineIds.length === 0 ? [] : (( await purchaseQueries.listPurchaseOrderLinesForMatching(db, { purchaseOrderLineIds }, ctx) ).value?.purchaseOrderLines ?? []); // Matching agrees the price, so fix it here: a later order price change must // not turn a matched line into a variance. const purchaseOrderLineById = new Map(purchaseOrderLines.map((poLine) => [poLine.id, poLine])); for (const line of lines) { if (line.sourceType !== "PURCHASE_ORDER" || line.purchaseOrderLineId === null) continue; const purchaseOrderLine = purchaseOrderLineById.get(line.purchaseOrderLineId); if (!purchaseOrderLine) continue; line.matchedPurchaseOrderUnitPrice = purchaseOrderLine.unitPrice; await db .updateTable("AccountPayableDocumentLine") .set({ matchedPurchaseOrderUnitPrice: purchaseOrderLine.unitPrice }) .where("id", "=", line.id) .execute(); } // An order repriced since creation would leave a stale derived split behind. const derivedResult = await deriveReceiptDistributions( db, { companyId: document.companyId, supplierAccountId: document.supplierAccountId, lines }, ctx, purchaseQueries, inventoryQueries, ); if (!derivedResult.ok) return derivedResult; const staleDerivedIds = distributions .filter((distribution) => distribution.distributionType !== "MANUAL") .map((distribution) => distribution.id); if (staleDerivedIds.length > 0) { await db .deleteFrom("AccountPayableDistributionLine") .where("id", "in", staleDerivedIds) .execute(); } const derivedInserts = [...derivedResult.value].flatMap(([index, rows]) => rows.map((row) => ({ accountPayableDocumentLineId: lines[index].id, accountId: row.accountId, amount: row.amount, distributionType: row.distributionType, correctionOfDistributionLineId: null, description: null, })), ); if (derivedInserts.length > 0) { await db.insertInto("AccountPayableDistributionLine").values(derivedInserts).execute(); } const holdsToCreate = planAccountPayableSystemHolds({ lines, purchaseOrderLines, alreadyInvoicedQuantities, tolerance, }); if (holdsToCreate.length > 0) { await db .insertInto("AccountPayableDocumentHold") .values( holdsToCreate.map((hold) => ({ accountPayableDocumentId: input.id, accountPayableDocumentLineId: hold.accountPayableDocumentLineId, reasonCode: hold.reasonCode, source: "SYSTEM" as const, releasedAt: null, releaseReason: null, })), ) .execute(); } const registeredDocument = await db .updateTable("AccountPayableDocument") .set({ status: nextStatus, registeredAt: new Date(), }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); const billingSyncResult = await syncPurchaseOrderBillingStatusFromAccountPayable( db, { document: registeredDocument, lines }, ctx, purchaseCommands, ); if (!billingSyncResult.ok) return billingSyncResult; return ok({ accountPayableDocument: registeredDocument }); }