diff --git a/app/actions/xero-daily-batch.ts b/app/actions/xero-daily-batch.ts index bf439d16..a8b59a7e 100644 --- a/app/actions/xero-daily-batch.ts +++ b/app/actions/xero-daily-batch.ts @@ -7,6 +7,7 @@ import { parseCostLayerSnapshot, sumCostLayerSnapshot } from '@/lib/cost-layer-s import { calculateCoverageByLine, requirementsMapToRows } from '@/lib/products/fulfillment-coverage' import { expandFulfillmentRequirementsDecimal, loadFulfillmentProductGraph } from '@/lib/products/kit-fulfillment' import { isFullyShippedTerminalStatus, recognizeShipmentRevenue } from '@/lib/domain/accounting/revenue-recognition' +import { loadPostedUnearnedReversalByOrder, loadFullyShippedNetOfRefundsOrderIds } from '@/lib/domain/accounting/deferred-reversal' import { addMoney, multiplyMoney, @@ -16,6 +17,7 @@ import { type Decimal, type DecimalInput, } from '@/lib/domain/math/decimal' +import { getXeroSettings } from '@/lib/connectors/xero/settings' /** * Preview & history for the Xero daily batch sub-ledger. @@ -295,10 +297,21 @@ async function computePreview(): Promise { bShipmentsByOrder.set(shipment.orderId, existing) } + // scjz.68: mirror the posting path's reversal-aware deferred true-up so the preview + // matches what actually posts (scjz.69) for PARTIALLY_REFUNDED orders. + const bSettings = await getXeroSettings() + const previewOrderIds = Array.from(bShipmentsByOrder.keys()) + const previewPostedUnearnedReversalByOrder = await loadPostedUnearnedReversalByOrder(db, { + orderIds: previewOrderIds, + connector: 'xero', + unearnedAccountCode: bSettings.xero_unearned_revenue_account, + }) + const previewFullyShippedNetOfRefundsOrderIds = await loadFullyShippedNetOfRefundsOrderIds(db, previewOrderIds) + // Compute per-shipment results grouped by order, then emit in the original // (createdAt asc) order so the displayed list and the 200-cap are stable. const bShipmentResults = new Map() - for (const [, orderShipments] of bShipmentsByOrder) { + for (const [orderId, orderShipments] of bShipmentsByOrder) { const firstShipment = orderShipments[0] const order = firstShipment.order const deferredBase = Number(order.unearnedRevenueAmount ?? order.totalBase) @@ -315,7 +328,9 @@ async function computePreview(): Promise { const recognizedPreviously = order.shipments.reduce((sum, shipment) => ( shipment.shipmentJournalDate ? sum + Number(shipment.revenueRecognizedAmount ?? 0) : sum ), 0) - const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously)) + // scjz.68: reversal-aware, matching the posting path. + const postedUnearnedReversal = previewPostedUnearnedReversalByOrder.get(orderId) ?? 0 + const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously - postedUnearnedReversal)) let runningRevenue = 0 for (let index = 0; index < orderShipments.length; index++) { @@ -338,12 +353,17 @@ async function computePreview(): Promise { const proportionalRevenue = orderLineTotal > 0 ? round2((shipmentLineValue / orderLineTotal) * deferredBase) : 0 + // scjz.68: mirror the posting path — true up a PARTIALLY_REFUNDED order only when it + // is fully shipped net of refunds. const revenueProportion = recognizeShipmentRevenue({ proportionalRevenue, remainingDeferred, runningRevenue, isFinalShipmentOfFullyShippedTerminalOrder: - isFullyShippedTerminalStatus(order.status) && index === orderShipments.length - 1, + index === orderShipments.length - 1 && ( + isFullyShippedTerminalStatus(order.status) || + (order.status === 'PARTIALLY_REFUNDED' && previewFullyShippedNetOfRefundsOrderIds.has(orderId)) + ), }) runningRevenue += revenueProportion diff --git a/lib/connectors/quickbooks/daily-sync.ts b/lib/connectors/quickbooks/daily-sync.ts index 6dfa85e7..ed13076a 100644 --- a/lib/connectors/quickbooks/daily-sync.ts +++ b/lib/connectors/quickbooks/daily-sync.ts @@ -35,6 +35,7 @@ import { addMoney, roundQuantity, subtractMoney, toDecimal, type Decimal } from import { GL_BASE_PRECISION, roundToGlPrecisionNumber } from '@/lib/domain/math/precision-policy' import { calculateCoverageByLine, requirementsMapToRows } from '@/lib/products/fulfillment-coverage' import { isFullyShippedTerminalStatus, recognizeShipmentRevenue } from '@/lib/domain/accounting/revenue-recognition' +import { loadPostedUnearnedReversalByOrder, loadFullyShippedNetOfRefundsOrderIds } from '@/lib/domain/accounting/deferred-reversal' import { recreateJournaledDateFilter } from '@/lib/domain/accounting/daily-batch-retention' import { expandFulfillmentRequirementsDecimal, loadFulfillmentProductGraph } from '@/lib/products/kit-fulfillment' @@ -761,6 +762,17 @@ export async function runDailyBatchSync(): Promise<{ }), ]) + // scjz.68: per-order posted UNEARNED_REV_REVERSAL (deferral reversed by refunds of + // unshipped lines), so the deferred-revenue true-up below is reversal-aware and the + // PARTIALLY_REFUNDED true-up only fires once no material unshipped value remains. + const postedUnearnedReversalByOrder = await loadPostedUnearnedReversalByOrder(tx, { + orderIds, + connector: QBO_CONNECTOR, + unearnedAccountCode: settings.quickbooks_unearned_revenue_account, + }) + // scjz.68: PARTIALLY_REFUNDED orders that are nonetheless fully shipped net of refunds. + const fullyShippedNetOfRefundsOrderIds = await loadFullyShippedNetOfRefundsOrderIds(tx, orderIds) + const referencedCostLayerIds = Array.from(new Set( orderAllocations.flatMap((allocation) => ( parseCostLayerSnapshot(allocation.costLayerSnapshot).map((entry) => entry.costLayerId) @@ -835,7 +847,10 @@ export async function runDailyBatchSync(): Promise<{ const recognizedPreviously = firstShipment.order.shipments.reduce((sum, shipment) => ( shipment.shipmentJournalDate ? sum + Number(shipment.revenueRecognizedAmount ?? 0) : sum ), 0) - const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously)) + // scjz.68: subtract deferral already reversed by refunds of unshipped lines, so a + // PARTIALLY_REFUNDED order's remaining deferred reflects only still-unshipped value. + const postedUnearnedReversal = postedUnearnedReversalByOrder.get(orderId) ?? 0 + const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously - postedUnearnedReversal)) let runningRevenue = 0 for (let index = 0; index < orderShipments.length; index++) { @@ -863,7 +878,12 @@ export async function runDailyBatchSync(): Promise<{ remainingDeferred, runningRevenue, isFinalShipmentOfFullyShippedTerminalOrder: - isFullyShippedTerminalStatus(firstShipment.order.status) && index === orderShipments.length - 1, + index === orderShipments.length - 1 && ( + isFullyShippedTerminalStatus(firstShipment.order.status) || + // scjz.68: see Xero — true up a PARTIALLY_REFUNDED order only when it is fully + // shipped net of refunds. + (firstShipment.order.status === 'PARTIALLY_REFUNDED' && fullyShippedNetOfRefundsOrderIds.has(orderId)) + ), }) const shipmentSnapshotsForLines = shipment.lines.map((line) => ( diff --git a/lib/connectors/xero/daily-sync.ts b/lib/connectors/xero/daily-sync.ts index 2070420e..1bc98465 100644 --- a/lib/connectors/xero/daily-sync.ts +++ b/lib/connectors/xero/daily-sync.ts @@ -40,6 +40,7 @@ import { buildInventoryReconciliationSweepJournal, loadInventoryGlReconciliation import { recreateJournaledDateFilter } from '@/lib/domain/accounting/daily-batch-retention' import { calculateCoverageByLine, requirementsMapToRows } from '@/lib/products/fulfillment-coverage' import { isFullyShippedTerminalStatus, recognizeShipmentRevenue } from '@/lib/domain/accounting/revenue-recognition' +import { loadPostedUnearnedReversalByOrder, loadFullyShippedNetOfRefundsOrderIds } from '@/lib/domain/accounting/deferred-reversal' import { expandFulfillmentRequirementsDecimal, loadFulfillmentProductGraph } from '@/lib/products/kit-fulfillment' type MutableLayer = { @@ -850,6 +851,18 @@ export async function runDailyBatchSync(): Promise { }), ]) + // scjz.68: per-order posted UNEARNED_REV_REVERSAL (deferral reversed by refunds of + // unshipped lines), so the deferred-revenue true-up below is reversal-aware and the + // PARTIALLY_REFUNDED true-up only fires once no material unshipped value remains. + const postedUnearnedReversalByOrder = await loadPostedUnearnedReversalByOrder(tx, { + orderIds, + connector: XERO_CONNECTOR, + unearnedAccountCode: settings.xero_unearned_revenue_account, + }) + // scjz.68: PARTIALLY_REFUNDED orders that are nonetheless fully shipped net of refunds + // — eligible for the final deferred-revenue true-up. + const fullyShippedNetOfRefundsOrderIds = await loadFullyShippedNetOfRefundsOrderIds(tx, orderIds) + const referencedCostLayerIds = Array.from(new Set( orderAllocations.flatMap((allocation) => ( parseCostLayerSnapshot(allocation.costLayerSnapshot).map((entry) => entry.costLayerId) @@ -930,7 +943,10 @@ export async function runDailyBatchSync(): Promise { const recognizedPreviously = firstShipment.order.shipments.reduce((sum, shipment) => ( shipment.shipmentJournalDate ? sum + Number(shipment.revenueRecognizedAmount ?? 0) : sum ), 0) - const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously)) + // scjz.68: subtract deferral already reversed by refunds of unshipped lines, so a + // PARTIALLY_REFUNDED order's remaining deferred reflects only still-unshipped value. + const postedUnearnedReversal = postedUnearnedReversalByOrder.get(orderId) ?? 0 + const remainingDeferred = round2(Math.max(0, deferredBase - recognizedPreviously - postedUnearnedReversal)) let runningRevenue = 0 for (let index = 0; index < orderShipments.length; index++) { @@ -958,7 +974,13 @@ export async function runDailyBatchSync(): Promise { remainingDeferred, runningRevenue, isFinalShipmentOfFullyShippedTerminalOrder: - isFullyShippedTerminalStatus(firstShipment.order.status) && index === orderShipments.length - 1, + index === orderShipments.length - 1 && ( + isFullyShippedTerminalStatus(firstShipment.order.status) || + // scjz.68: a PARTIALLY_REFUNDED order isn't a fully-shipped status, but can be + // genuinely fully shipped net of refunds — then its remaining (reversal-aware) + // deferred revenue, incl. the order-level shipping share, is earned and trued up. + (firstShipment.order.status === 'PARTIALLY_REFUNDED' && fullyShippedNetOfRefundsOrderIds.has(orderId)) + ), }) // COGS: prefer immutable shipment-line snapshots when present. diff --git a/lib/domain/accounting/deferred-reversal.ts b/lib/domain/accounting/deferred-reversal.ts new file mode 100644 index 00000000..e81dbe0a --- /dev/null +++ b/lib/domain/accounting/deferred-reversal.ts @@ -0,0 +1,131 @@ +import type { PrismaClient } from '@/app/generated/prisma/client' +import { parseCostLayerSnapshot } from '@/lib/cost-layer-snapshots' +import { extractUnearnedReversalDebit } from './revenue-recognition' + +/** Both the live transaction client and the top-level db client satisfy this. */ +type DeferredReversalClient = Pick + +type FullShipClient = Pick + +// Engine-scale qty tolerance (matches the 6dp FIFO engine) so fractional rounding on a +// fully-shipped line isn't mistaken for a remaining unshipped sliver. +const FULL_SHIP_QTY_TOLERANCE = 1e-6 + +/** + * scjz.68: order IDs whose every SHIPPABLE product line is fully shipped once + * refunded-while-unshipped units are excluded — i.e. nothing more will ship, so the + * remaining deferred revenue (incl. the order-level shipping share) is safe to true up. + * + * Not used for SHIPPED/COMPLETED/DELIVERED orders (their status already guarantees this); + * it lets PARTIALLY_REFUNDED orders — which can sit at that status while genuinely fully + * shipped — qualify for the final true-up without the residual-threshold heuristic, which + * couldn't tell unshipped product value apart from unrecognized shipping. + * + * Per line: shipped qty (all shipments) + refunded-while-UNSHIPPED qty must cover the + * ordered qty. A refund counts as unshipped only when its cost snapshot reversed an + * allocation (no `shipment`-source entry) — a return of a shipped unit does not reduce the + * ship obligation, and a refund with no snapshot is treated conservatively as not-unshipped. + * Non-shippable lines (no product, or NON_INVENTORY service/fee) never ship and are ignored. + */ +export async function loadFullyShippedNetOfRefundsOrderIds( + client: FullShipClient, + orderIds: string[], +): Promise> { + const result = new Set() + if (orderIds.length === 0) return result + + const [orderLines, shipmentLines, refundLines] = await Promise.all([ + client.salesOrderLine.findMany({ + where: { orderId: { in: orderIds } }, + select: { id: true, orderId: true, qty: true, productId: true, product: { select: { type: true } } }, + }), + // Only DISPATCHED (SHIPPED) shipments count as shipped — a PENDING/PICKING/PACKED + // shipment hasn't gone out yet (Codex). + client.shipmentLine.findMany({ + where: { shipment: { orderId: { in: orderIds }, status: 'SHIPPED' } }, + select: { lineId: true, qty: true }, + }), + client.salesOrderRefundLine.findMany({ + where: { refund: { orderId: { in: orderIds } } }, + select: { salesOrderLineId: true, qty: true, costLayerSnapshot: true }, + }), + ]) + + const shippedByLine = new Map() + for (const sl of shipmentLines) { + shippedByLine.set(sl.lineId, (shippedByLine.get(sl.lineId) ?? 0) + Number(sl.qty)) + } + const refundedUnshippedByLine = new Map() + for (const rl of refundLines) { + if (!rl.salesOrderLineId) continue + // Count only the allocation-source (unshipped) QTY — a refund line can mix shipped + // (returned) and unshipped units; returns don't reduce the ship obligation (Codex). + const allocationQty = parseCostLayerSnapshot(rl.costLayerSnapshot) + .reduce((sum, entry) => (entry.source === 'allocation' ? sum + Number(entry.qty) : sum), 0) + if (allocationQty <= 0) continue + refundedUnshippedByLine.set(rl.salesOrderLineId, (refundedUnshippedByLine.get(rl.salesOrderLineId) ?? 0) + allocationQty) + } + + const linesByOrder = new Map>() + for (const line of orderLines) { + const type = line.product?.type + const shippable = !!line.productId && type !== 'NON_INVENTORY' + // KIT/BOM lines ship at component granularity, so a raw parent-line qty sum can't tell + // full shipment safely. Don't risk a false-positive true-up — leave such orders deferred. + const componentBacked = type === 'KIT' || type === 'BOM' + const arr = linesByOrder.get(line.orderId) ?? [] + arr.push({ id: line.id, qty: Number(line.qty), shippable, componentBacked }) + linesByOrder.set(line.orderId, arr) + } + for (const [orderId, lines] of linesByOrder) { + if (lines.some((line) => line.componentBacked)) continue + const fullyShipped = lines.every((line) => ( + !line.shippable || + (shippedByLine.get(line.id) ?? 0) + (refundedUnshippedByLine.get(line.id) ?? 0) + FULL_SHIP_QTY_TOLERANCE >= line.qty + )) + if (fullyShipped) result.add(orderId) + } + return result +} + +/** + * scjz.68: sum the already-posted UNEARNED_REV_REVERSAL (refund-of-unshipped-lines + * deferral reversal) per order, so the daily-batch deferred-revenue true-up — and its + * preview — can be reversal-aware and never re-recognize what a refund already reversed. + * Shared by the Xero + QuickBooks daily syncs and the Xero daily-batch preview so all + * three agree on the remaining deferred balance. + */ +export async function loadPostedUnearnedReversalByOrder( + client: DeferredReversalClient, + opts: { orderIds: string[]; connector: string; unearnedAccountCode: string }, +): Promise> { + const byOrder = new Map() + if (opts.orderIds.length === 0) return byOrder + + const refunds = await client.salesOrderRefund.findMany({ + where: { orderId: { in: opts.orderIds } }, + select: { id: true, orderId: true }, + }) + if (refunds.length === 0) return byOrder + + const refundIdToOrderId = new Map(refunds.map((refund) => [refund.id, refund.orderId])) + const logs = await client.accountingSyncLog.findMany({ + where: { + connector: opts.connector, + type: 'UNEARNED_REV_REVERSAL', + referenceType: 'SalesOrderRefund', + referenceId: { in: refunds.map((refund) => refund.id) }, + status: { in: ['PENDING', 'PROCESSING', 'SYNCED'] }, + }, + select: { referenceId: true, payload: true }, + }) + for (const log of logs) { + const orderId = refundIdToOrderId.get(log.referenceId) + if (!orderId) continue + byOrder.set( + orderId, + (byOrder.get(orderId) ?? 0) + extractUnearnedReversalDebit(log.payload, opts.unearnedAccountCode), + ) + } + return byOrder +} diff --git a/lib/domain/accounting/revenue-recognition.ts b/lib/domain/accounting/revenue-recognition.ts index 74834110..f75ab1d6 100644 --- a/lib/domain/accounting/revenue-recognition.ts +++ b/lib/domain/accounting/revenue-recognition.ts @@ -31,6 +31,20 @@ export function isFullyShippedTerminalStatus(status: SalesOrderStatus | string): return FULLY_SHIPPED_TERMINAL_STATUS_SET.has(status) } +/** + * scjz.68: sum the debit posted to the unearned-revenue account in an + * UNEARNED_REV_REVERSAL journal payload (DR unearned / CR sales). This is the amount of + * deferred revenue a refund of unshipped lines already reversed, which the daily-batch + * true-up must subtract from the deferred base so it never re-recognizes it. + */ +export function extractUnearnedReversalDebit(payload: unknown, unearnedAccountCode: string): number { + const lines = (payload as { lines?: Array<{ accountCode?: string; debit?: number }> } | null)?.lines + if (!Array.isArray(lines)) return 0 + return lines.reduce((sum, line) => ( + line.accountCode === unearnedAccountCode ? sum + (Number(line.debit) || 0) : sum + ), 0) +} + function round2(value: number): number { return Math.round(value * 100) / 100 } diff --git a/tests/domain/accounting/deferred-reversal.test.ts b/tests/domain/accounting/deferred-reversal.test.ts new file mode 100644 index 00000000..cb7cffc5 --- /dev/null +++ b/tests/domain/accounting/deferred-reversal.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { loadFullyShippedNetOfRefundsOrderIds } from '@/lib/domain/accounting/deferred-reversal' + +type OrderLine = { id: string; orderId: string; qty: number; productId: string | null; product: { type: string } | null } +type ShipLine = { lineId: string; qty: number } +type RefundLine = { salesOrderLineId: string | null; qty: number; costLayerSnapshot: unknown } + +function mockClient(input: { orderLines: OrderLine[]; shipmentLines: ShipLine[]; refundLines: RefundLine[] }) { + return { + salesOrderLine: { findMany: async () => input.orderLines }, + shipmentLine: { findMany: async () => input.shipmentLines }, + salesOrderRefundLine: { findMany: async () => input.refundLines }, + } as never +} + +const alloc = (qty: number) => [{ costLayerId: 'cl', qty, unitCostBase: 1, source: 'allocation' as const }] +const shipped = (qty: number) => [{ costLayerId: 'cl', qty, unitCostBase: 1, source: 'shipment' as const }] + +test('scjz.68: loadFullyShippedNetOfRefundsOrderIds — full shipment, refunds, returns, services, kits, mixed', async () => { + const result = await loadFullyShippedNetOfRefundsOrderIds(mockClient({ + orderLines: [ + { id: 'L1', orderId: 'fully', qty: 2, productId: 'p', product: { type: 'SIMPLE' } }, + { id: 'L2', orderId: 'refunded', qty: 2, productId: 'p', product: { type: 'SIMPLE' } }, + { id: 'L3', orderId: 'partial', qty: 2, productId: 'p', product: { type: 'SIMPLE' } }, + { id: 'L4', orderId: 'return', qty: 2, productId: 'p', product: { type: 'SIMPLE' } }, + { id: 'L5', orderId: 'service', qty: 1, productId: 'svc', product: { type: 'NON_INVENTORY' } }, + { id: 'L6', orderId: 'service', qty: 1, productId: 'p', product: { type: 'SIMPLE' } }, + { id: 'L7', orderId: 'kit', qty: 1, productId: 'k', product: { type: 'KIT' } }, + { id: 'L8', orderId: 'mixed', qty: 2, productId: 'p', product: { type: 'SIMPLE' } }, + ], + shipmentLines: [ + { lineId: 'L1', qty: 2 }, // fully shipped + { lineId: 'L2', qty: 1 }, // 1 shipped, 1 will be refunded-unshipped + { lineId: 'L3', qty: 1 }, // 1 shipped, 1 NOT accounted for + { lineId: 'L4', qty: 1 }, // 1 shipped, 1 unshipped; the refund below is a RETURN of a shipped unit + { lineId: 'L6', qty: 1 }, // the shippable line of the service order + { lineId: 'L7', qty: 5 }, // kit shipped at component granularity — must NOT be trusted + { lineId: 'L8', qty: 1 }, // 1 shipped; mixed refund below covers the other 1 (alloc portion) + ], + refundLines: [ + { salesOrderLineId: 'L2', qty: 1, costLayerSnapshot: alloc(1) }, // unshipped refund -> covers the gap + { salesOrderLineId: 'L4', qty: 1, costLayerSnapshot: shipped(1) }, // return of a shipped unit -> does NOT cover the unshipped one + // mixed refund line: 1 unshipped (allocation) + 1 return (shipment) — only the alloc 1 counts. + { salesOrderLineId: 'L8', qty: 2, costLayerSnapshot: [...alloc(1), ...shipped(1)] }, + ], + }), ['fully', 'refunded', 'partial', 'return', 'service', 'kit', 'mixed']) + + assert.equal(result.has('fully'), true) + assert.equal(result.has('refunded'), true) // 1 shipped + 1 refunded-unshipped >= 2 + assert.equal(result.has('partial'), false) // 1 shipped, 1 unaccounted + assert.equal(result.has('return'), false) // return doesn't reduce the ship obligation + assert.equal(result.has('service'), true) // non-inventory line ignored; shippable line fully shipped + assert.equal(result.has('kit'), false) // KIT line -> conservatively skipped + assert.equal(result.has('mixed'), true) // 1 shipped + alloc 1 (mixed refund) >= 2 +}) + +test('scjz.68: empty order list returns empty set', async () => { + const result = await loadFullyShippedNetOfRefundsOrderIds(mockClient({ orderLines: [], shipmentLines: [], refundLines: [] }), []) + assert.equal(result.size, 0) +}) diff --git a/tests/domain/accounting/revenue-recognition.test.ts b/tests/domain/accounting/revenue-recognition.test.ts index f4037ceb..9987053e 100644 --- a/tests/domain/accounting/revenue-recognition.test.ts +++ b/tests/domain/accounting/revenue-recognition.test.ts @@ -5,8 +5,26 @@ import { FULLY_SHIPPED_TERMINAL_STATUSES, isFullyShippedTerminalStatus, recognizeShipmentRevenue, + extractUnearnedReversalDebit, } from '@/lib/domain/accounting/revenue-recognition' +test('scjz.68: PARTIALLY_REFUNDED is NOT a fully-shipped terminal status', () => { + // It must go through the reversal-aware remainder gate, not the unconditional status true-up. + assert.equal(isFullyShippedTerminalStatus('PARTIALLY_REFUNDED'), false) +}) + +test('scjz.68: extractUnearnedReversalDebit sums the unearned-account debit only', () => { + const payload = { lines: [ + { accountCode: 'UNEARNED', debit: 10 }, + { accountCode: 'SALES', credit: 10 }, + { accountCode: 'UNEARNED', debit: 2.5 }, + ] } + assert.equal(extractUnearnedReversalDebit(payload, 'UNEARNED'), 12.5) + assert.equal(extractUnearnedReversalDebit(payload, 'SALES'), 0) + assert.equal(extractUnearnedReversalDebit(null, 'UNEARNED'), 0) + assert.equal(extractUnearnedReversalDebit({}, 'UNEARNED'), 0) +}) + test('fully-shipped terminal statuses true up deferred revenue', () => { // These reach a terminal post-shipment state, so the final shipment must // recognize all remaining deferred revenue (scjz.41).