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')
+})