Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/(dashboard)/purchase-orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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'),
)
Expand All @@ -59,7 +67,7 @@ export default async function PurchaseOrderDetailPage({ params }: Props) {
</Link>
<h1 className="text-2xl font-semibold font-mono">{po.reference}</h1>
</div>
<PoDetailClient po={po} suppliers={suppliers} products={products} warehouses={warehouses} currencies={currencies} taxRates={taxRates} purchaseUnits={purchaseUnits} carriers={carriers} companyHomeCountry={organisation?.country ?? null} accountingAvailable={accountingAvailable} accountingBillUrlTemplate={billUrlTemplate ?? accountingSettings.billUrlTemplate} mintsoftAsnState={mintsoftAsnState} rejectedAccountingSyncs={rejectedAccountingSyncs} />
<PoDetailClient po={po} suppliers={suppliers} products={products} warehouses={warehouses} currencies={currencies} taxRates={taxRates} purchaseUnits={purchaseUnits} carriers={carriers} companyHomeCountry={organisation?.country ?? null} accountingAvailable={accountingAvailable} accountingBillUrlTemplate={billUrlTemplate ?? accountingSettings.billUrlTemplate} mintsoftAsnState={mintsoftAsnState} rejectedAccountingSyncs={rejectedAccountingSyncs} overBilling={overBilling} />
</div>
)
}
49 changes: 48 additions & 1 deletion app/(dashboard)/purchase-orders/[id]/po-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -67,6 +69,7 @@ type Props = {
accountingBillUrlTemplate: string
mintsoftAsnState: MintsoftPurchaseOrderAsnState
rejectedAccountingSyncs: RejectedAccountingDocumentUpdateWarning[]
overBilling: PurchaseOrderOverBillingSummary
}

const STATUS_LABELS: Record<PoStatus, string> = {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1795,6 +1798,7 @@ export function PoDetailClient({ po: initialPo, suppliers, products, warehouses,
const [editBillFor, setEditBillFor] = useState<InvoiceRow | null>(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [cancelConsumedCost, setCancelConsumedCost] = useState<PurchaseOrderConsumedCostSummary | null>(null)

const canEdit = po.status === 'DRAFT'
const canRfq = po.status === 'DRAFT' || po.status === 'RFQ_SENT'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -1989,6 +1997,45 @@ export function PoDetailClient({ po: initialPo, suppliers, products, warehouses,

{error && <p className="text-sm text-destructive">{error}</p>}
{notice && <p className="text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
{overBilling.hasOverBilling && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div className="space-y-2">
<p className="font-medium">
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.
</p>
<ul className="space-y-1 text-xs">
{overBilling.lines.map((line) => (
<li key={line.poLineId}>
<span className="font-mono">{line.sku ?? line.productId}</span>: billed {line.billedQty}, kept {line.netReceivedQty} →{' '}
{line.overBilledQty} over-billed ({formatMoney(Number(line.overBilledValueBase), baseCurrency.symbol, baseCurrency.symbolPosition)})
</li>
))}
</ul>
<p className="text-xs">
Affected bills (gross total incl. tax): {overBilling.bills.map((b) => `${b.invoiceNumber ?? b.invoiceId} (${formatMoney(Number(b.totalBase), baseCurrency.symbol, baseCurrency.symbolPosition)})`).join(', ')}
</p>
</div>
</div>
</div>
)}
{cancelConsumedCost && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="font-medium">
{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.
</p>
</div>
</div>
</div>
)}
{rejectedAccountingSyncs.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex gap-2">
Expand Down
46 changes: 45 additions & 1 deletion app/actions/purchase-orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PurchaseOrderOverBillingSummary> => {
const purchaseReturn = await tx.purchaseReturn.create({
data: {
poId: id,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -2286,6 +2303,7 @@ export async function returnPurchaseOrder(
idempotencyKey: accountingPayloadKey(`purchase-return:${purchaseReturn.id}`, payload),
})
}
return overBillingComputed
}, STOCK_TX_OPTIONS)

revalidatePath('/purchase-orders')
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 43 additions & 2 deletions lib/domain/purchasing/cancellation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -48,6 +57,7 @@ const defaultCancelPurchaseOrderServiceDeps: CancelPurchaseOrderServiceDeps = {
getAccountingSettings,
queueAccountingSyncTx,
reversePurchaseOrderCostLayersForCancellation,
readPurchaseOrderConsumedCostForCancellation,
}

async function logPurchaseOrderCancellationNoop(
Expand Down Expand Up @@ -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' } })
Expand Down Expand Up @@ -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) {
Expand All @@ -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')
Expand All @@ -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,
Expand Down
Loading
Loading