From 8b47cc4996679d8254cc23614defff215fd10afc Mon Sep 17 00:00:00 2001 From: OneTwo3D IMS Date: Wed, 17 Jun 2026 16:13:37 +0000 Subject: [PATCH] fix(landed-cost): keep un-journaled-shipment COGS delta in the journal when the daily batch is off (gbzh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 3aph. refreshShipmentCogsForCostLayerChange claimed an un-journaled shipment's COGS revaluation delta (so callers drop it from the landed-cost / manufacturing COGS journal) on the assumption the daily batch would later post the updated cogsBatchAmount. If the daily batch is globally disabled, the batch never posts it, so the delta posted NOWHERE — an under-count. Gate the un-journaled claim on the daily batch actually being enabled (new isDailyBatchPostingEnabled() — active connector + sync_enabled + daily_batch_enabled, mirroring app/api/cron/accounting-daily-batch/route.ts), symmetric with the journaled COGS_REVERSAL gate. When the batch is off, the delta stays in the COGS journal so it still posts exactly once. The check is resolved at most once per call and injectable for tests. bd: onetwo3d-ims-gbzh Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/accounting.ts | 21 +++++++++++++++++++++ lib/cost-layers.ts | 23 +++++++++++++++++++---- tests/cost-layers.test.ts | 30 +++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/accounting.ts b/lib/accounting.ts index 875030c5..c37d67cd 100644 --- a/lib/accounting.ts +++ b/lib/accounting.ts @@ -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 { + 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 { return (await getAccountingPostingContext(type)) !== null } diff --git a/lib/cost-layers.ts b/lib/cost-layers.ts index 00213bf2..c144c578 100644 --- a/lib/cost-layers.ts +++ b/lib/cost-layers.ts @@ -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 { @@ -35,6 +35,13 @@ type ShipmentCogsRevaluationSyncOptions = { * this delta (audit-3aph). */ isReversalPostingEnabled?: () => Promise + /** + * 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 } export function buildShipmentCogsRevaluationSyncPayload(input: { @@ -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 }, @@ -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) } updated++ } diff --git a/tests/cost-layers.test.ts b/tests/cost-layers.test.ts index bbdcca86..38e949e1 100644 --- a/tests/cost-layers.test.ts +++ b/tests/cost-layers.test.ts @@ -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 () => {