Skip to content
Open
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
26 changes: 23 additions & 3 deletions app/actions/xero-daily-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -295,10 +297,21 @@ async function computePreview(): Promise<DailyBatchPreview> {
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<string, { revenue: number; cogs: Decimal }>()
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)
Expand All @@ -315,7 +328,9 @@ async function computePreview(): Promise<DailyBatchPreview> {
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++) {
Expand All @@ -338,12 +353,17 @@ async function computePreview(): Promise<DailyBatchPreview> {
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

Expand Down
24 changes: 22 additions & 2 deletions lib/connectors/quickbooks/daily-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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) => (
Expand Down
26 changes: 24 additions & 2 deletions lib/connectors/xero/daily-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -850,6 +851,18 @@ export async function runDailyBatchSync(): Promise<XeroDailyBatchResult> {
}),
])

// 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)
Expand Down Expand Up @@ -930,7 +943,10 @@ export async function runDailyBatchSync(): Promise<XeroDailyBatchResult> {
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++) {
Expand Down Expand Up @@ -958,7 +974,13 @@ export async function runDailyBatchSync(): Promise<XeroDailyBatchResult> {
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.
Expand Down
131 changes: 131 additions & 0 deletions lib/domain/accounting/deferred-reversal.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaClient, 'salesOrderRefund' | 'accountingSyncLog'>

type FullShipClient = Pick<PrismaClient, 'salesOrderLine' | 'shipmentLine' | 'salesOrderRefundLine'>

// 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<Set<string>> {
const result = new Set<string>()
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<string, number>()
for (const sl of shipmentLines) {
shippedByLine.set(sl.lineId, (shippedByLine.get(sl.lineId) ?? 0) + Number(sl.qty))
}
const refundedUnshippedByLine = new Map<string, number>()
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<string, Array<{ id: string; qty: number; shippable: boolean; componentBacked: boolean }>>()
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<Map<string, number>> {
const byOrder = new Map<string, number>()
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
}
14 changes: 14 additions & 0 deletions lib/domain/accounting/revenue-recognition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading