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
21 changes: 21 additions & 0 deletions lib/accounting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,27 @@ async function getAccountingPostingContext(type: AccountingSyncType): Promise<{
return { connector, postingMode }
}

/**
* Whether the daily batch will actually post shipment COGS for the active
* connector — i.e. the connector is active, its sync is enabled, AND its daily
* batch is enabled. Used to decide whether an un-journaled shipment's COGS
* revaluation will reach the ledger via the batch, or whether the landed-cost
* COGS journal must still carry it (audit-gbzh). Mirrors the gate in
* app/api/cron/accounting-daily-batch/route.ts.
*/
export async function isDailyBatchPostingEnabled(): Promise<boolean> {
const connector = await getActiveAccountingConnectorId()
if (!connector) return false
if (connector === 'xero') {
const { getXeroSettings } = await import('@/lib/connectors/xero/settings')
const settings = await getXeroSettings()
return settings.xero_sync_enabled === 'true' && settings.xero_daily_batch_enabled === 'true'
}
const { getQuickBooksSettings } = await import('@/lib/connectors/quickbooks/settings')
const settings = await getQuickBooksSettings()
return settings.quickbooks_sync_enabled === 'true' && settings.quickbooks_daily_batch_enabled === 'true'
}

export async function isAccountingSyncTypeEnabled(type: AccountingSyncType): Promise<boolean> {
return (await getAccountingPostingContext(type)) !== null
}
Expand Down
23 changes: 19 additions & 4 deletions lib/cost-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type { Prisma } from '@/app/generated/prisma/client'
import { getAccountingSettings, isAccountingSyncTypeEnabled, queueAccountingSyncTx } from '@/lib/accounting'
import { getAccountingSettings, isAccountingSyncTypeEnabled, isDailyBatchPostingEnabled, queueAccountingSyncTx } from '@/lib/accounting'
import { parseCostLayerSnapshot, serializeCostLayerSnapshot, sumCostLayerSnapshot } from '@/lib/cost-layer-snapshots'
import { getInventoryConstraintMessage } from '@/lib/domain/inventory/prisma-errors'
import {
Expand All @@ -35,6 +35,13 @@ type ShipmentCogsRevaluationSyncOptions = {
* this delta (audit-3aph).
*/
isReversalPostingEnabled?: () => Promise<boolean>
/**
* Whether the daily batch will post un-journaled shipments' COGS (injectable
* for tests). When false, the batch won't carry an un-journaled shipment's
* revaluation, so the caller must keep that delta in the COGS journal
* (audit-gbzh).
*/
isDailyBatchPostingEnabled?: () => Promise<boolean>
}

export function buildShipmentCogsRevaluationSyncPayload(input: {
Expand Down Expand Up @@ -731,6 +738,9 @@ export async function refreshShipmentCogsForCostLayerChange(

let updated = 0
let cogsRevaluationDelta = toDecimal(0)
// Resolved lazily on the first un-journaled shipment, then reused, so a
// settings read happens at most once per call (audit-gbzh).
let dailyBatchPosts: boolean | null = null
for (const shipment of shipments) {
const currentShipment = await tx.shipment.findUnique({
where: { id: shipment.id },
Expand Down Expand Up @@ -765,9 +775,14 @@ export async function refreshShipmentCogsForCostLayerChange(
}, options)
if (posted) cogsRevaluationDelta = addMoney(cogsRevaluationDelta, shipmentDelta)
} else {
// Not yet journaled → the daily batch will post the updated cogsBatchAmount
// (new cost), so the shipment path owns this delta.
cogsRevaluationDelta = addMoney(cogsRevaluationDelta, shipmentDelta)
// Not yet journaled → the daily batch posts the updated cogsBatchAmount
// (new cost), so the shipment path owns this delta — but ONLY if the daily
// batch is actually enabled; otherwise it posts nowhere, so leave the delta
// in the COGS journal (audit-gbzh).
if (dailyBatchPosts === null) {
dailyBatchPosts = await (options.isDailyBatchPostingEnabled ?? isDailyBatchPostingEnabled)()
}
if (dailyBatchPosts) cogsRevaluationDelta = addMoney(cogsRevaluationDelta, shipmentDelta)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid double-counting when the batch is re-enabled

If the daily batch is disabled during this revaluation but is enabled before the affected shipment is journaled, this branch leaves the delta in the current landed-cost/manufacturing journal while the function has already persisted the recomputed cogsBatchAmount on the shipment. The daily batch Group B path later selects shipmentJournalDate: null shipments and posts the recomputed snapshot/COGS amount before marking them journaled (mirrored in both Xero and QuickBooks daily sync), so the same revaluation delta can be posted once in the current journal and again by the later batch. Consider either not persisting the recomputed shipment COGS when the delta is kept in the journal, or recording that the delta has already been journaled so the later batch does not include it again.

Useful? React with 👍 / 👎.

}
updated++
}
Expand Down
30 changes: 29 additions & 1 deletion tests/cost-layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,42 @@ test('refreshShipmentCogsForCostLayerChange does not queue COGS revaluation sync
},
}

await refreshShipmentCogsForCostLayerChange(tx as never, 'layer-1', {
const result = await refreshShipmentCogsForCostLayerChange(tx as never, 'layer-1', {
accountingSettings: { inventoryAccount: '120', cogsAccount: '500' },
isDailyBatchPostingEnabled: async () => true,
queueAccountingSync: async (_tx, params) => {
queued.push(params)
},
})

assert.deepEqual(queued, []) // un-journaled → no COGS_REVERSAL now
// Batch IS enabled → it will post the updated cost, so the shipment path owns
// the delta (new 27.5 − old 20 = 7.5) and the caller drops it from the journal.
assert.equal(result.cogsRevaluationDelta.toString(), '7.5')
})

test('refreshShipmentCogsForCostLayerChange keeps the un-journaled delta in the journal when the daily batch is disabled (audit-gbzh)', async () => {
// Un-journaled shipment, but the daily batch is OFF → it will never post the
// updated cost, so the helper must NOT claim the delta (else the caller drops
// it from the COGS journal and it posts nowhere — an under-count).
const queued: unknown[] = []
const tx = {
$queryRawUnsafe: async () => [{ id: 'shipment-1' }],
shipment: {
findUnique: async () => ({ cogsBatchAmount: '20.00', shipmentJournalDate: null }),
update: async () => {},
},
shipmentLine: {
findMany: async () => [{ costLayerSnapshot: [{ costLayerId: 'layer-1', qty: '5.000000', unitCostBase: '5.500000' }] }],
},
}
const result = await refreshShipmentCogsForCostLayerChange(tx as never, 'layer-1', {
accountingSettings: { inventoryAccount: '120', cogsAccount: '500' },
isDailyBatchPostingEnabled: async () => false,
queueAccountingSync: async (_tx, params) => { queued.push(params) },
})
assert.deepEqual(queued, [])
assert.equal(result.cogsRevaluationDelta.toString(), '0') // not shipment-owned → stays in the COGS journal
})

test('consumeFifoLayers selects FIFO candidates with row locks before consuming', async () => {
Expand Down
Loading