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 () => {