diff --git a/app/(dashboard)/purchase-orders/[id]/page.tsx b/app/(dashboard)/purchase-orders/[id]/page.tsx index 63a183c9..39f8660c 100644 --- a/app/(dashboard)/purchase-orders/[id]/page.tsx +++ b/app/(dashboard)/purchase-orders/[id]/page.tsx @@ -14,6 +14,7 @@ import { getOrganisation } from '@/app/actions/company' import { getAccountingSettings } from '@/lib/accounting' import { isIntegrationPluginEnabled } from '@/lib/integration-plugins' import { DEFAULT_CARRIERS } from '@/lib/tracking' +import { computePurchaseOrderOverBilling } from '@/lib/domain/purchasing/purchasing-reversal-alerts' import { PoDetailClient } from './po-detail-client' export const metadata: Metadata = { title: 'Purchase Order' } @@ -47,6 +48,13 @@ export default async function PurchaseOrderDetailPage({ params }: Props) { let carriers: string[] = DEFAULT_CARRIERS try { if (carriersJson) carriers = JSON.parse(carriersJson) } catch { /* empty */ } + // audit-C4: surface bills that are over-billed relative to the quantity kept + // after returns, so finance can raise a supplier credit. + const overBilling = computePurchaseOrderOverBilling({ + lines: po.lines.map((l) => ({ id: l.id, productId: l.productId, sku: l.sku, qtyReceived: l.qtyReceived, qtyReturned: l.qtyReturned })), + invoices: po.invoices.map((inv) => ({ id: inv.id, invoiceNumber: inv.invoiceNumber, totalBase: inv.totalBase, lines: inv.lines.map((il) => ({ poLineId: il.poLineId, qtyBilled: il.qtyBilled, totalBase: il.totalBase })) })), + }) + const products = productsResult.products.filter( (p) => !['VARIABLE', 'NON_INVENTORY', 'KIT'].includes(p.type) && (p.lifecycleStatus === 'ACTIVE' || p.lifecycleStatus === 'DRAFT'), ) @@ -59,7 +67,7 @@ export default async function PurchaseOrderDetailPage({ params }: Props) {

{po.reference}

- + ) } diff --git a/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx b/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx index 58851add..afb164ac 100644 --- a/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx +++ b/app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx @@ -41,6 +41,8 @@ import { createMintsoftPurchaseOrderAsn } from '@/app/actions/mintsoft-sync' import { getTrackingUrl } from '@/lib/tracking' import type { AccountingBankAccount } from '@/lib/accounting' import type { RejectedAccountingDocumentUpdateWarning } from '@/lib/domain/accounting/rejected-sync-warnings' +import type { PurchaseOrderOverBillingSummary } from '@/lib/domain/purchasing/purchasing-reversal-alerts' +import type { PurchaseOrderConsumedCostSummary } from '@/lib/domain/purchasing/po-cancellation' import type { SupplierRow } from '@/app/actions/suppliers' import type { ProductRow } from '@/app/actions/products' import type { CurrencyRow } from '@/app/actions/currencies' @@ -67,6 +69,7 @@ type Props = { accountingBillUrlTemplate: string mintsoftAsnState: MintsoftPurchaseOrderAsnState rejectedAccountingSyncs: RejectedAccountingDocumentUpdateWarning[] + overBilling: PurchaseOrderOverBillingSummary } const STATUS_LABELS: Record = { @@ -1762,7 +1765,7 @@ function ShipDialog({ // Main detail component // --------------------------------------------------------------------------- -export function PoDetailClient({ po: initialPo, suppliers, products, warehouses, currencies, taxRates, purchaseUnits, carriers, companyHomeCountry, accountingAvailable, accountingBillUrlTemplate, mintsoftAsnState, rejectedAccountingSyncs }: Props) { +export function PoDetailClient({ po: initialPo, suppliers, products, warehouses, currencies, taxRates, purchaseUnits, carriers, companyHomeCountry, accountingAvailable, accountingBillUrlTemplate, mintsoftAsnState, rejectedAccountingSyncs, overBilling }: Props) { const baseCurrency = useBaseCurrency() const router = useRouter() const [isPending, startTransition] = useTransition() @@ -1795,6 +1798,7 @@ export function PoDetailClient({ po: initialPo, suppliers, products, warehouses, const [editBillFor, setEditBillFor] = useState(null) const [error, setError] = useState('') const [notice, setNotice] = useState('') + const [cancelConsumedCost, setCancelConsumedCost] = useState(null) const canEdit = po.status === 'DRAFT' const canRfq = po.status === 'DRAFT' || po.status === 'RFQ_SENT' @@ -1867,10 +1871,14 @@ export function PoDetailClient({ po: initialPo, suppliers, products, warehouses, if (!confirm('Cancel this purchase order?')) return setError('') setNotice('') + setCancelConsumedCost(null) startTransition(async () => { const result = await cancelPurchaseOrder(po.id) if (result.success) { if (result.notice) setNotice(result.notice) + if (result.consumedCost && Number(result.consumedCost.consumedQty) > 0) { + setCancelConsumedCost(result.consumedCost) + } router.refresh() } else setError(result.error ?? 'Failed') @@ -1989,6 +1997,45 @@ export function PoDetailClient({ po: initialPo, suppliers, products, warehouses, {error &&

{error}

} {notice &&

{notice}

} + {overBilling.hasOverBilling && ( +
+
+ +
+

+ Supplier bill exceeds goods kept after return. {overBilling.totalOverBilledQty} unit(s) are billed but were returned —{' '} + {formatMoney(Number(overBilling.totalOverBilledValueBase), baseCurrency.symbol, baseCurrency.symbolPosition)} over-billed. + Raise a supplier credit to reduce the AP liability; IMS does not adjust the bill automatically. +

+
    + {overBilling.lines.map((line) => ( +
  • + {line.sku ?? line.productId}: billed {line.billedQty}, kept {line.netReceivedQty} →{' '} + {line.overBilledQty} over-billed ({formatMoney(Number(line.overBilledValueBase), baseCurrency.symbol, baseCurrency.symbolPosition)}) +
  • + ))} +
+

+ Affected bills (gross total incl. tax): {overBilling.bills.map((b) => `${b.invoiceNumber ?? b.invoiceId} (${formatMoney(Number(b.totalBase), baseCurrency.symbol, baseCurrency.symbolPosition)})`).join(', ')} +

+
+
+
+ )} + {cancelConsumedCost && ( +
+
+ +
+

+ {cancelConsumedCost.consumedQty} unit(s) had already been sold or used from this PO before cancellation. + {' '}{formatMoney(Number(cancelConsumedCost.consumedValueBase), baseCurrency.symbol, baseCurrency.symbolPosition)} of COGS + remains booked against the now-cancelled receipt. Review with finance — IMS does not reverse COGS for units that have left stock. +

+
+
+
+ )} {rejectedAccountingSyncs.length > 0 && (
diff --git a/app/actions/purchase-orders.ts b/app/actions/purchase-orders.ts index ae0c3ca7..7c0d347a 100644 --- a/app/actions/purchase-orders.ts +++ b/app/actions/purchase-orders.ts @@ -48,6 +48,7 @@ import { } from '@/lib/domain/purchasing/landed-cost-service' import type { CancelPurchaseOrderResult } from '@/lib/domain/purchasing/cancellation-service' import { assertFinitePurchaseReceiptUnitCost } from '@/lib/domain/purchasing/purchase-receipt-cost' +import { computePurchaseOrderOverBilling, type PurchaseOrderOverBillingSummary } from '@/lib/domain/purchasing/purchasing-reversal-alerts' import { validateLinkedFreightReceiptStatus, validatePurchaseOrderStatusTransition, @@ -2171,7 +2172,7 @@ export async function returnPurchaseOrder( const returnRef = `RTN-${po.reference}-${Date.now().toString(36).toUpperCase()}` let purchaseReturnId = '' let totalReturnedCostBase = toDecimal(0) - await db.$transaction(async (tx) => { + const overBilling = await db.$transaction(async (tx): Promise => { const purchaseReturn = await tx.purchaseReturn.create({ data: { poId: id, @@ -2259,6 +2260,22 @@ export async function returnPurchaseOrder( data: { status: allReceivedReturned ? 'RETURNED' : 'PARTIALLY_RETURNED' }, }) } + + // audit-C4: compute over-billing inside the tx so qtyReturned reflects + // exactly this return (a concurrent return cannot inflate the figure). + const billingLines = await tx.purchaseOrderLine.findMany({ + where: { poId: id }, + select: { id: true, productId: true, qtyReceived: true, qtyReturned: true, product: { select: { sku: true } } }, + }) + const billingInvoices = await tx.purchaseInvoice.findMany({ + where: { poId: id }, + select: { id: true, invoiceNumber: true, totalBase: true, lines: { select: { poLineId: true, qtyBilled: true, totalBase: true } } }, + }) + const overBillingComputed = computePurchaseOrderOverBilling({ + lines: billingLines.map((l) => ({ id: l.id, productId: l.productId, sku: l.product?.sku ?? null, qtyReceived: l.qtyReceived, qtyReturned: l.qtyReturned })), + invoices: billingInvoices.map((inv) => ({ id: inv.id, invoiceNumber: inv.invoiceNumber, totalBase: inv.totalBase, lines: inv.lines })), + }) + if (accountingSettings.syncEnabled && totalReturnedCostBase.gt(0.000001)) { const amount = roundQuantity(totalReturnedCostBase, 2).toNumber() const payload = { @@ -2286,6 +2303,7 @@ export async function returnPurchaseOrder( idempotencyKey: accountingPayloadKey(`purchase-return:${purchaseReturn.id}`, payload), }) } + return overBillingComputed }, STOCK_TX_OPTIONS) revalidatePath('/purchase-orders') @@ -2311,6 +2329,32 @@ export async function returnPurchaseOrder( metadata: { reference: po.reference, lineCount: linesWithQty.length, reason }, }) + // audit-C4: returns reverse stock/cost but never touch supplier bills. When a + // line is now billed beyond what is kept, flag the over-billed bill(s) so + // finance can raise a supplier credit. Computed inside the tx above; logging + // is isolated so a log failure can't fail the already-committed return. + try { + if (overBilling.hasInvoices && overBilling.hasOverBilling) { + await logActivity({ + entityType: 'PURCHASE_ORDER', + entityId: id, + action: 'return_overbilled_bill', + tag: 'purchase', + level: 'WARNING', + description: `Return on PO ${po.reference} leaves ${overBilling.totalOverBilledQty} unit(s) billed but not kept — ${overBilling.totalOverBilledValueBase} over-billed (base currency) across ${overBilling.bills.length} bill(s). Raise a supplier credit.`, + metadata: { + reference: po.reference, + totalOverBilledQty: overBilling.totalOverBilledQty, + totalOverBilledValueBase: overBilling.totalOverBilledValueBase, + overBilledLines: overBilling.lines, + bills: overBilling.bills, + }, + }) + } + } catch (billingWarnError) { + console.error(billingWarnError) + } + try { const returnedPairs = linesWithQty.map((rl) => ({ productId: po.lines.find((l) => l.id === rl.poLineId)!.productId, diff --git a/docs/workflows.md b/docs/workflows.md index 1d224982..19e254ce 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -109,6 +109,22 @@ closure, and return state. Receipt actions move orders to `PARTIALLY_RECEIVED` or `RECEIVED`; supplier return actions can move eligible orders to `PARTIALLY_RETURNED` or `RETURNED`. +**Returns vs. supplier bills.** A return reverses stock and FIFO cost layers but +does **not** adjust supplier invoices already recorded against the PO. When a +return leaves a line billed for more than the quantity now kept +(received − returned), the PO detail page shows an amber over-billing alert and a +`return_overbilled_bill` WARNING activity-log row (naming the bills and the +over-billed amount). Reducing the AP liability is a manual step — raise a +supplier credit; IMS does not yet post a credit memo automatically (audit-C4). + +**Cancellation and already-consumed cost.** Cancelling a PO reverses the +remaining (still-on-hand) receipt cost layers. Units already sold or used keep +their COGS booked against the cancelled receipt. When that consumed quantity is +non-zero, cancellation emits a `cancelled_consumed_cogs_standing` WARNING and the +UI surfaces the consumed units and value for finance review — IMS does not +reverse COGS for stock that has already left (audit-H8). Cancellation remains +blocked entirely once a supplier invoice exists. + ### Refunds Refunds do not currently have a persisted status column. The refund workflow is diff --git a/lib/domain/purchasing/cancellation-service.ts b/lib/domain/purchasing/cancellation-service.ts index 951451f5..db115db1 100644 --- a/lib/domain/purchasing/cancellation-service.ts +++ b/lib/domain/purchasing/cancellation-service.ts @@ -8,7 +8,9 @@ import { roundQuantity } from '@/lib/domain/math/decimal' import { assertPurchaseOrderCancellationHasNoInvoices, isPurchaseOrderCancellationNoop, + readPurchaseOrderConsumedCostForCancellation, reversePurchaseOrderCostLayersForCancellation, + type PurchaseOrderConsumedCostSummary, type PurchaseOrderCostLayerReversal, } from '@/lib/domain/purchasing/po-cancellation' import { validatePurchaseOrderStatusTransition } from '@/lib/domain/workflows/action-guards' @@ -20,6 +22,12 @@ export type CancelPurchaseOrderResult = { error?: string notice?: string reversedCostLayers?: PurchaseOrderCostLayerReversal[] + /** + * Cost of units already consumed (sold/used) from this PO before cancellation. + * Their COGS stays booked against the cancelled receipt — surfaced so finance + * can decide whether a correction is needed (audit-H8). + */ + consumedCost?: PurchaseOrderConsumedCostSummary } export type CancelPurchaseOrderServiceDeps = { @@ -33,6 +41,7 @@ export type CancelPurchaseOrderServiceDeps = { getAccountingSettings: typeof getAccountingSettings queueAccountingSyncTx: typeof queueAccountingSyncTx reversePurchaseOrderCostLayersForCancellation: typeof reversePurchaseOrderCostLayersForCancellation + readPurchaseOrderConsumedCostForCancellation: typeof readPurchaseOrderConsumedCostForCancellation } // Production dependencies are captured at module load; tests that need @@ -48,6 +57,7 @@ const defaultCancelPurchaseOrderServiceDeps: CancelPurchaseOrderServiceDeps = { getAccountingSettings, queueAccountingSyncTx, reversePurchaseOrderCostLayersForCancellation, + readPurchaseOrderConsumedCostForCancellation, } async function logPurchaseOrderCancellationNoop( @@ -101,10 +111,16 @@ export async function cancelPurchaseOrderService( if (!transition.success) throw new Error(transition.error) assertPurchaseOrderCancellationHasNoInvoices(existing._count.invoices) + const poLineIds = existing.lines.map((line) => line.id) + + // Read consumed cost BEFORE the reversal, otherwise the remaining quantity + // it is about to zero out would be miscounted as already-consumed. + const consumedCost = await deps.readPurchaseOrderConsumedCostForCancellation(tx, poLineIds) + const reversal = await deps.reversePurchaseOrderCostLayersForCancellation(tx, { poId: id, poReference: existing.reference, - poLineIds: existing.lines.map((line) => line.id), + poLineIds, }) await tx.purchaseOrder.update({ where: { id }, data: { status: 'CANCELLED' } }) @@ -140,7 +156,7 @@ export async function cancelPurchaseOrderService( } } - return { alreadyCancelled: false as const, existing, reversal } + return { alreadyCancelled: false as const, existing, reversal, consumedCost } }, PURCHASE_ORDER_CANCELLATION_TX_OPTIONS) if (cancellation.alreadyCancelled) { @@ -164,6 +180,30 @@ export async function cancelPurchaseOrderService( }, }) + const consumedCost = cancellation.consumedCost + if (Number(consumedCost.consumedQty) > 0) { + // Isolate from the success path: the PO is already cancelled and committed, + // so a log failure must not turn a successful cancellation into an error. + try { + await deps.logActivity({ + entityType: 'PURCHASE_ORDER', + entityId: id, + action: 'cancelled_consumed_cogs_standing', + tag: 'purchase', + level: 'WARNING', + description: `Cancelled PO ${cancellation.existing.reference} with ${consumedCost.consumedQty} unit(s) already sold/used — ${consumedCost.consumedValueBase} of COGS (base currency) remains booked against the cancelled receipt. Review with finance.`, + metadata: { + reference: cancellation.existing.reference, + consumedQty: consumedCost.consumedQty, + consumedValueBase: consumedCost.consumedValueBase, + consumedLayers: consumedCost.layers, + }, + }) + } catch (consumedLogError) { + console.error('Failed to log consumed-COGS warning:', consumedLogError) + } + } + if (cancellation.reversal.productIds.length > 0) { try { await deps.enqueueStockSync(cancellation.reversal.productIds, 'IMS_CHANGE') @@ -189,6 +229,7 @@ export async function cancelPurchaseOrderService( return { success: true, reversedCostLayers: cancellation.reversal.reversedLayers, + consumedCost, notice: cancellation.reversal.reversedLayers.length > 0 ? `Cancelled PO and reversed ${cancellation.reversal.reversedLayers.length} remaining receipt cost layer(s).` : undefined, diff --git a/lib/domain/purchasing/po-cancellation.ts b/lib/domain/purchasing/po-cancellation.ts index 81fa4cf3..ff104930 100644 --- a/lib/domain/purchasing/po-cancellation.ts +++ b/lib/domain/purchasing/po-cancellation.ts @@ -38,6 +38,93 @@ export type ReversePurchaseOrderCostLayersResult = { totalReversalValueBase: Decimal } +// --------------------------------------------------------------------------- +// Already-consumed cost on cancellation (audit-H8) +// +// reversePurchaseOrderCostLayersForCancellation only reverses the remaining +// (still-on-hand) quantity of each cost layer. Units already consumed — sold, +// used in production, written off — keep their COGS sourced from the now-cancelled +// PO. Inventory reconciles, but the P&L silently carries cost from a cancelled +// receipt. This summarises that consumed portion (receivedQty − remainingQty) +// so the cancellation can flag it for finance review. Read BEFORE the reversal +// runs, so the about-to-be-reversed remaining quantity is not mistaken for +// consumption. +// --------------------------------------------------------------------------- + +export type PurchaseOrderConsumedCostLayer = { + costLayerId: string + poLineId: string | null + productId: string + consumedQty: string + unitCostBase: string + consumedValueBase: string +} + +export type PurchaseOrderConsumedCostSummary = { + consumedQty: string + consumedValueBase: string + layers: PurchaseOrderConsumedCostLayer[] +} + +type ConsumedCostLayerRow = { + id: string + poLineId: string | null + productId: string + receivedQty: DecimalInput + remainingQty: DecimalInput + unitCostBase: DecimalInput +} + +export function summarizeConsumedCostLayers(rows: ConsumedCostLayerRow[]): PurchaseOrderConsumedCostSummary { + let consumedQty = toDecimal(0) + let consumedValueBase = toDecimal(0) + const layers: PurchaseOrderConsumedCostLayer[] = [] + + for (const row of rows) { + const consumed = subtractMoney(row.receivedQty, row.remainingQty) + if (consumed.lte(0)) continue + const unitCostBase = toDecimal(row.unitCostBase) + const valueBase = roundQuantity(multiplyMoney(consumed, unitCostBase), 6) + consumedQty = addMoney(consumedQty, consumed) + consumedValueBase = addMoney(consumedValueBase, valueBase) + layers.push({ + costLayerId: row.id, + poLineId: row.poLineId, + productId: row.productId, + consumedQty: roundQuantity(consumed, 4).toString(), + unitCostBase: roundQuantity(unitCostBase, 6).toString(), + consumedValueBase: valueBase.toString(), + }) + } + + return { + consumedQty: roundQuantity(consumedQty, 4).toString(), + consumedValueBase: roundQuantity(consumedValueBase, 2).toString(), + layers, + } +} + +export async function readPurchaseOrderConsumedCostForCancellation( + tx: TxClient, + poLineIds: string[], +): Promise { + if (poLineIds.length === 0) { + return { consumedQty: '0', consumedValueBase: '0', layers: [] } + } + // No FOR UPDATE: the consumed (remainingQty < receivedQty) portion of a layer + // is immutable, and this runs in the same tx immediately before the reversal + // locks the remaining>0 layers. ORDER BY only gives the metadata layer list a + // stable order; it does not affect the summed totals. + const rows = await tx.$queryRaw` + SELECT id, "poLineId", "productId", "receivedQty", "remainingQty", "unitCostBase" + FROM "cost_layers" + WHERE "poLineId" = ANY(${poLineIds}::text[]) + AND "receivedQty" > "remainingQty" + ORDER BY "receivedAt" ASC, id ASC + ` + return summarizeConsumedCostLayers(rows) +} + export function assertPurchaseOrderCancellationHasNoInvoices(invoiceCount: number): void { if (invoiceCount > 0) { throw new Error('Cannot cancel a purchase order after supplier invoices have been recorded. Create a supplier credit or bill reversal instead.') diff --git a/lib/domain/purchasing/purchasing-reversal-alerts.ts b/lib/domain/purchasing/purchasing-reversal-alerts.ts new file mode 100644 index 00000000..15042fdf --- /dev/null +++ b/lib/domain/purchasing/purchasing-reversal-alerts.ts @@ -0,0 +1,138 @@ +import { Prisma } from '@/app/generated/prisma/client' +import { + addMoney, + multiplyMoney, + roundQuantity, + subtractMoney, + toDecimal, + type DecimalInput, +} from '@/lib/domain/math/decimal' + +// --------------------------------------------------------------------------- +// Purchase-order returns vs. supplier bills (audit-C4) +// +// returnPurchaseOrder reverses stock and FIFO cost layers but never touches the +// supplier invoices already recorded against the PO. Returning goods that have +// been billed therefore leaves the AP liability standing at the full amount with +// no system prompt. This computes, for a PO, how much each line has been billed +// against the quantity actually kept (received − returned) so the UI and the +// activity log can flag the over-billed bills and amount. Phase 1 is alert-only; +// it does not create a credit memo. +// --------------------------------------------------------------------------- + +export type PurchaseOrderOverBillingLine = { + poLineId: string + productId: string + sku: string | null + billedQty: string + netReceivedQty: string + overBilledQty: string + /** overBilledQty × the line's average billed unit cost, in base currency. */ + overBilledValueBase: string +} + +export type PurchaseOrderOverBillingBill = { + invoiceId: string + invoiceNumber: string | null + totalBase: string +} + +export type PurchaseOrderOverBillingSummary = { + /** True when the PO has at least one recorded supplier invoice. */ + hasInvoices: boolean + /** True when at least one line is billed beyond the quantity now kept. */ + hasOverBilling: boolean + totalOverBilledQty: string + totalOverBilledValueBase: string + lines: PurchaseOrderOverBillingLine[] + bills: PurchaseOrderOverBillingBill[] +} + +type PoLineInput = { + id: string + productId: string + sku?: string | null + qtyReceived: DecimalInput + qtyReturned: DecimalInput +} + +type InvoiceInput = { + id: string + invoiceNumber: string | null + totalBase: DecimalInput + lines: Array<{ + poLineId: string | null + qtyBilled: DecimalInput + totalBase: DecimalInput + }> +} + +export function computePurchaseOrderOverBilling(input: { + lines: PoLineInput[] + invoices: InvoiceInput[] +}): PurchaseOrderOverBillingSummary { + const hasInvoices = input.invoices.length > 0 + + // Sum billed quantity and value per PO line across every invoice line, and + // remember which invoices billed each line (so we can name only the bills that + // actually contributed to an over-billed line, not every bill on the PO). + const billedByLine = new Map }>() + for (const invoice of input.invoices) { + for (const line of invoice.lines) { + if (!line.poLineId) continue + const existing = billedByLine.get(line.poLineId) ?? { qty: toDecimal(0), valueBase: toDecimal(0), invoiceIds: new Set() } + existing.qty = addMoney(existing.qty, line.qtyBilled) + existing.valueBase = addMoney(existing.valueBase, line.totalBase) + existing.invoiceIds.add(invoice.id) + billedByLine.set(line.poLineId, existing) + } + } + + let totalOverBilledQty = toDecimal(0) + let totalOverBilledValueBase = toDecimal(0) + const lines: PurchaseOrderOverBillingLine[] = [] + const contributingInvoiceIds = new Set() + + for (const poLine of input.lines) { + const billed = billedByLine.get(poLine.id) + if (!billed || billed.qty.lte(0)) continue + // Clamp to 0: a corrupt qtyReturned > qtyReceived must not inflate over-billing. + const netReceived = Prisma.Decimal.max(subtractMoney(poLine.qtyReceived, poLine.qtyReturned), toDecimal(0)) + const overBilledQty = subtractMoney(billed.qty, netReceived) + if (overBilledQty.lte(0)) continue + + // Average billed unit cost for the line; guards against a zero qty divide. + const avgUnitCostBase = billed.qty.gt(0) ? billed.valueBase.div(billed.qty) : toDecimal(0) + const overBilledValueBase = roundQuantity(multiplyMoney(overBilledQty, avgUnitCostBase), 2) + + totalOverBilledQty = addMoney(totalOverBilledQty, overBilledQty) + totalOverBilledValueBase = addMoney(totalOverBilledValueBase, overBilledValueBase) + for (const invoiceId of billed.invoiceIds) contributingInvoiceIds.add(invoiceId) + lines.push({ + poLineId: poLine.id, + productId: poLine.productId, + sku: poLine.sku ?? null, + billedQty: roundQuantity(billed.qty, 4).toString(), + netReceivedQty: roundQuantity(netReceived, 4).toString(), + overBilledQty: roundQuantity(overBilledQty, 4).toString(), + overBilledValueBase: overBilledValueBase.toString(), + }) + } + + return { + hasInvoices, + hasOverBilling: lines.length > 0, + totalOverBilledQty: roundQuantity(totalOverBilledQty, 4).toString(), + totalOverBilledValueBase: roundQuantity(totalOverBilledValueBase, 2).toString(), + lines, + // Only the bills that billed an over-billed line — totalBase is the gross + // bill total (incl. tax/freight), shown for reference, not the over-billed value. + bills: input.invoices + .filter((invoice) => contributingInvoiceIds.has(invoice.id)) + .map((invoice) => ({ + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + totalBase: roundQuantity(toDecimal(invoice.totalBase), 2).toString(), + })), + } +} diff --git a/tests/domain/purchasing/po-cancellation.test.ts b/tests/domain/purchasing/po-cancellation.test.ts index d9800666..44974967 100644 --- a/tests/domain/purchasing/po-cancellation.test.ts +++ b/tests/domain/purchasing/po-cancellation.test.ts @@ -10,6 +10,7 @@ import { assertPurchaseOrderCancellationHasNoInvoices, isPurchaseOrderCancellationNoop, reversePurchaseOrderCostLayersForCancellation, + summarizeConsumedCostLayers, } from '@/lib/domain/purchasing/po-cancellation' type CostLayerRow = { @@ -310,6 +311,11 @@ test('cancelPurchaseOrderService is idempotent when called twice', async () => { totalReversalValueBase: new Prisma.Decimal('10'), } }, + readPurchaseOrderConsumedCostForCancellation: async () => ({ + consumedQty: '0', + consumedValueBase: '0', + layers: [], + }), } assert.deepEqual(await cancelPurchaseOrderService('po-1', deps), { @@ -323,6 +329,7 @@ test('cancelPurchaseOrderService is idempotent when called twice', async () => { unitCostBase: '5.000000', totalValueBase: '10.000000', }], + consumedCost: { consumedQty: '0', consumedValueBase: '0', layers: [] }, notice: 'Cancelled PO and reversed 1 remaining receipt cost layer(s).', }) assert.equal(po.status, 'CANCELLED') @@ -336,3 +343,70 @@ test('cancelPurchaseOrderService is idempotent when called twice', async () => { assert.deepEqual(logs.map((log) => log.action), ['cancelled', 'cancelled_noop']) assert.equal(logs.filter((log) => log.action === 'cancelled').length, 1) }) + +test('summarizeConsumedCostLayers totals consumed (received − remaining) value and skips fully-remaining layers', () => { + const summary = summarizeConsumedCostLayers([ + // 70 of 100 consumed @ £5 => £350 + { id: 'l1', poLineId: 'pol-1', productId: 'p1', receivedQty: '100', remainingQty: '30', unitCostBase: '5' }, + // nothing consumed yet — excluded + { id: 'l2', poLineId: 'pol-1', productId: 'p1', receivedQty: '40', remainingQty: '40', unitCostBase: '5' }, + // 10 of 10 consumed @ £2.5 => £25 + { id: 'l3', poLineId: 'pol-2', productId: 'p2', receivedQty: '10', remainingQty: '0', unitCostBase: '2.5' }, + ]) + assert.equal(summary.consumedQty, '80') + assert.equal(summary.consumedValueBase, '375') + assert.equal(summary.layers.length, 2) + assert.deepEqual(summary.layers.map((l) => l.costLayerId), ['l1', 'l3']) +}) + +test('cancelPurchaseOrderService flags already-consumed COGS with a WARNING and returns consumedCost', async () => { + const po: { status: PurchaseOrderStatus; reference: string } = { status: 'PARTIALLY_RECEIVED', reference: 'PO-9' } + const logs: Array<{ action: string; level?: string; metadata?: unknown }> = [] + + const tx = { + purchaseOrder: { + findUnique: async () => ({ + status: po.status, + reference: po.reference, + lines: [{ id: 'po-line-1' }], + _count: { invoices: 0 }, + }), + update: async ({ data }: { data: { status: typeof po.status } }) => { + po.status = data.status + return { id: 'po-9' } + }, + }, + } + let consumedReadBeforeReversal = false + let reversalRan = false + const deps: CancelPurchaseOrderServiceDeps = { + findPurchaseOrderFast: async () => ({ status: po.status, reference: po.reference }), + transaction: async (fn) => fn(tx as never), + logActivity: async (input) => { logs.push({ action: input.action, level: input.level, metadata: input.metadata }) }, + enqueueStockSync: async () => {}, + getAccountingSettings: async () => ({ syncEnabled: false, transitAccount: '140', inventoryAccount: '120' }) as never, + queueAccountingSyncTx: async () => ({ id: 'sync-1' }) as never, + readPurchaseOrderConsumedCostForCancellation: async () => { + // Must be read before the reversal zeroes remaining quantities. + consumedReadBeforeReversal = !reversalRan + return { consumedQty: '70', consumedValueBase: '350', layers: [{ costLayerId: 'l1', poLineId: 'po-line-1', productId: 'p1', consumedQty: '70', unitCostBase: '5', consumedValueBase: '350' }] } + }, + reversePurchaseOrderCostLayersForCancellation: async () => { + reversalRan = true + return { reversedLayers: [], productIds: [], totalReversalValueBase: new Prisma.Decimal('0') } + }, + } + + const result = await cancelPurchaseOrderService('po-9', deps) + assert.equal(result.success, true) + assert.deepEqual(result.consumedCost, { + consumedQty: '70', + consumedValueBase: '350', + layers: [{ costLayerId: 'l1', poLineId: 'po-line-1', productId: 'p1', consumedQty: '70', unitCostBase: '5', consumedValueBase: '350' }], + }) + assert.equal(consumedReadBeforeReversal, true) + const warning = logs.find((log) => log.action === 'cancelled_consumed_cogs_standing') + assert.ok(warning, 'expected a consumed-COGS WARNING activity log') + assert.equal(warning?.level, 'WARNING') + assert.deepEqual((warning?.metadata as { consumedValueBase: string }).consumedValueBase, '350') +}) diff --git a/tests/domain/purchasing/purchasing-reversal-alerts.test.ts b/tests/domain/purchasing/purchasing-reversal-alerts.test.ts new file mode 100644 index 00000000..028f500f --- /dev/null +++ b/tests/domain/purchasing/purchasing-reversal-alerts.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { computePurchaseOrderOverBilling } from '@/lib/domain/purchasing/purchasing-reversal-alerts' + +test('flags a line billed beyond what is kept after a return', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [ + // billed 100, kept 70 (100 received − 30 returned) => 30 over-billed + { id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 100, qtyReturned: 30 }, + ], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 1000, lines: [{ poLineId: 'pol-1', qtyBilled: 100, totalBase: 1000 }] }, + ], + }) + assert.equal(summary.hasInvoices, true) + assert.equal(summary.hasOverBilling, true) + assert.equal(summary.totalOverBilledQty, '30') + // avg unit cost £10 × 30 = £300 + assert.equal(summary.totalOverBilledValueBase, '300') + assert.equal(summary.lines.length, 1) + assert.equal(summary.lines[0].overBilledValueBase, '300') + assert.deepEqual(summary.bills, [{ invoiceId: 'inv-1', invoiceNumber: 'BILL-1', totalBase: '1000' }]) +}) + +test('no over-billing when billed quantity does not exceed kept quantity', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [{ id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 100, qtyReturned: 30 }], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 700, lines: [{ poLineId: 'pol-1', qtyBilled: 70, totalBase: 700 }] }, + ], + }) + assert.equal(summary.hasInvoices, true) + assert.equal(summary.hasOverBilling, false) + assert.equal(summary.totalOverBilledQty, '0') + assert.equal(summary.lines.length, 0) +}) + +test('reports no invoices when the PO is unbilled', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [{ id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 100, qtyReturned: 100 }], + invoices: [], + }) + assert.equal(summary.hasInvoices, false) + assert.equal(summary.hasOverBilling, false) +}) + +test('aggregates billed quantity across multiple bills for one line', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [{ id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 50, qtyReturned: 50 }], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 300, lines: [{ poLineId: 'pol-1', qtyBilled: 30, totalBase: 300 }] }, + { id: 'inv-2', invoiceNumber: 'BILL-2', totalBase: 200, lines: [{ poLineId: 'pol-1', qtyBilled: 20, totalBase: 200 }] }, + ], + }) + // billed 50 across two bills, kept 0 => 50 over-billed @ avg £10 = £500 + assert.equal(summary.hasOverBilling, true) + assert.equal(summary.totalOverBilledQty, '50') + assert.equal(summary.totalOverBilledValueBase, '500') + assert.equal(summary.bills.length, 2) +}) + +test('names only the bills that billed an over-billed line', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [ + // line A: over-billed (billed 10 via inv-1, kept 0) + { id: 'pol-A', productId: 'pA', sku: 'A', qtyReceived: 10, qtyReturned: 10 }, + // line B: correctly billed (billed 5 via inv-2, kept 5) + { id: 'pol-B', productId: 'pB', sku: 'B', qtyReceived: 5, qtyReturned: 0 }, + ], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 100, lines: [{ poLineId: 'pol-A', qtyBilled: 10, totalBase: 100 }] }, + { id: 'inv-2', invoiceNumber: 'BILL-2', totalBase: 50, lines: [{ poLineId: 'pol-B', qtyBilled: 5, totalBase: 50 }] }, + ], + }) + assert.equal(summary.hasOverBilling, true) + // Only inv-1 contributed to an over-billed line; inv-2 must not be named. + assert.deepEqual(summary.bills.map((b) => b.invoiceNumber), ['BILL-1']) +}) + +test('clamps a corrupt qtyReturned > qtyReceived so it does not inflate over-billing', () => { + const summary = computePurchaseOrderOverBilling({ + // billed 10, received 5 but qtyReturned 8 (corrupt) → netReceived clamps to 0, not -3 + lines: [{ id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 5, qtyReturned: 8 }], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 100, lines: [{ poLineId: 'pol-1', qtyBilled: 10, totalBase: 100 }] }, + ], + }) + // over-billed = billed 10 − netReceived 0 = 10 (NOT 13) + assert.equal(summary.totalOverBilledQty, '10') +}) + +test('ignores freight/cost invoice lines with no poLineId', () => { + const summary = computePurchaseOrderOverBilling({ + lines: [{ id: 'pol-1', productId: 'p1', sku: 'SKU-1', qtyReceived: 10, qtyReturned: 10 }], + invoices: [ + { id: 'inv-1', invoiceNumber: 'BILL-1', totalBase: 250, lines: [ + { poLineId: 'pol-1', qtyBilled: 10, totalBase: 100 }, + { poLineId: null, qtyBilled: 1, totalBase: 150 }, + ] }, + ], + }) + // Only the goods line counts: billed 10, kept 0 => 10 over-billed @ £10 = £100 + assert.equal(summary.totalOverBilledQty, '10') + assert.equal(summary.totalOverBilledValueBase, '100') +})