import { Decimal } from "decimal.js"; type HoldReasonCode = | "INVOICE_UNIT_DIFFERS_FROM_PURCHASE_ORDER_UNIT" | "INVOICE_UNIT_PRICE_DIFFERS_FROM_PURCHASE_ORDER_UNIT_PRICE" | "INVOICE_QUANTITY_EXCEEDS_ORDERED_QUANTITY" | "INVOICE_QUANTITY_EXCEEDS_RECEIVED_QUANTITY"; export type AccountPayableLineForMatching = { id: string; purchaseOrderLineId: string | null; quantity: string | null; unitPrice: string | null; unitId: string | null; correctionType?: "PRICE" | "QUANTITY" | "AMOUNT" | null; }; export type PurchaseOrderLineForMatching = { id: string; purchaseOrderId: string; quantity: string; unitPrice: string; unitId: string; requiresPhysicalReceipt: boolean; receivedQuantity: string; }; export interface AccountPayableToleranceLimit { absolute?: string; percent?: string; } export interface AccountPayableToleranceRule { unitPrice?: AccountPayableToleranceLimit; quantity?: AccountPayableToleranceLimit; } export interface PlannedHold { accountPayableDocumentLineId: string; reasonCode: HoldReasonCode; } export interface AlreadyInvoicedQuantity { purchaseOrderLineId: string; quantity: string; } function sumQuantityByPurchaseOrderLine( rows: T[], getLineId: (row: T) => string, getQuantity: (row: T) => string, ) { const quantityByLineId = new Map(); for (const row of rows) { const lineId = getLineId(row); quantityByLineId.set( lineId, (quantityByLineId.get(lineId) ?? new Decimal(0)).plus(getQuantity(row)), ); } return quantityByLineId; } function exceedsTolerance( difference: Decimal, basis: Decimal, tolerance: AccountPayableToleranceLimit | undefined, ) { if (!tolerance) return difference.gt(0); if (tolerance.absolute === undefined && tolerance.percent === undefined) return difference.gt(0); const withinAbsolute = tolerance.absolute !== undefined && difference.lte(tolerance.absolute); const withinPercent = tolerance.percent !== undefined && difference.lte(basis.abs().mul(tolerance.percent)); return !(withinAbsolute || withinPercent); } export function planAccountPayableSystemHolds(input: { lines: AccountPayableLineForMatching[]; purchaseOrderLines: PurchaseOrderLineForMatching[]; alreadyInvoicedQuantities: AlreadyInvoicedQuantity[]; tolerance?: AccountPayableToleranceRule; }): PlannedHold[] { const purchaseOrderLineById = new Map(input.purchaseOrderLines.map((line) => [line.id, line])); const alreadyInvoicedByLineId = sumQuantityByPurchaseOrderLine( input.alreadyInvoicedQuantities, (row) => row.purchaseOrderLineId, (row) => row.quantity, ); const invoiceQuantityByLineId = sumQuantityByPurchaseOrderLine( input.lines.filter( ( line, ): line is AccountPayableLineForMatching & { purchaseOrderLineId: string; quantity: string; } => line.correctionType == null && line.purchaseOrderLineId !== null && line.quantity !== null, ), (row) => row.purchaseOrderLineId, (row) => row.quantity, ); const plannedHolds: PlannedHold[] = []; for (const line of input.lines) { if (line.correctionType != null) continue; if ( !line.purchaseOrderLineId || line.quantity === null || line.unitPrice === null || line.unitId === null ) continue; const purchaseOrderLine = purchaseOrderLineById.get(line.purchaseOrderLineId); if (!purchaseOrderLine) continue; const invoiceQuantity = invoiceQuantityByLineId.get(purchaseOrderLine.id) ?? new Decimal(0); const alreadyInvoiced = alreadyInvoicedByLineId.get(purchaseOrderLine.id) ?? new Decimal(0); const remainingOrdered = new Decimal(purchaseOrderLine.quantity).minus(alreadyInvoiced); const remainingReceived = new Decimal(purchaseOrderLine.receivedQuantity).minus( alreadyInvoiced, ); if (line.unitId !== purchaseOrderLine.unitId) { plannedHolds.push({ accountPayableDocumentLineId: line.id, reasonCode: "INVOICE_UNIT_DIFFERS_FROM_PURCHASE_ORDER_UNIT", }); } const unitPriceDifference = new Decimal(line.unitPrice) .minus(purchaseOrderLine.unitPrice) .abs(); if ( exceedsTolerance( unitPriceDifference, new Decimal(purchaseOrderLine.unitPrice), input.tolerance?.unitPrice, ) ) { plannedHolds.push({ accountPayableDocumentLineId: line.id, reasonCode: "INVOICE_UNIT_PRICE_DIFFERS_FROM_PURCHASE_ORDER_UNIT_PRICE", }); } if ( exceedsTolerance( invoiceQuantity.minus(remainingOrdered), remainingOrdered, input.tolerance?.quantity, ) ) { plannedHolds.push({ accountPayableDocumentLineId: line.id, reasonCode: "INVOICE_QUANTITY_EXCEEDS_ORDERED_QUANTITY", }); } if ( purchaseOrderLine.requiresPhysicalReceipt && exceedsTolerance( invoiceQuantity.minus(remainingReceived), remainingReceived, input.tolerance?.quantity, ) ) { plannedHolds.push({ accountPayableDocumentLineId: line.id, reasonCode: "INVOICE_QUANTITY_EXCEEDS_RECEIVED_QUANTITY", }); } } return plannedHolds; }