Skip to content

fix(ci): repair qty-precision migration trigger dependency + regenerate stale workflow docs - #333

Merged
OneTwo3D merged 2 commits into
developmentfrom
ci/fix-migration-trigger-and-stale-workflow-docs
Jun 21, 2026
Merged

OneTwo3D merged 2 commits into
developmentfrom
ci/fix-migration-trigger-and-stale-workflow-docs

Conversation

@OneTwo3D

Copy link
Copy Markdown
Owner

Fixes the two pre-existing CI blockers that were red on every PR (not introduced by recent work — no recent PR touched migrations).

1. Migration trigger dependency (the real blocker)

20260619120000_cost_layer_qty_precision_14_6 alters stock_movements.qty to numeric(14,6), but the stock_movements_reporting_evidence_guard constraint trigger (20260602103000) lists qty in its UPDATE OF ... qty ... column set, so Postgres refuses: cannot alter type of a column used in a trigger definition (P3018). prisma migrate deploy failed on a fresh DB, blocking e2e-select, invariant-preflight, and schema-guardrails(fresh-db-drift) entirely. Fix: drop the trigger around the column rewrite, recreate it identically (trigger function unchanged). Only stock_movements.qty has a column-scoped trigger; the other widened columns have none.

2. Stale workflow docs

docs/workflows.md was out of date (failing docs-workflows). Regenerated.

Validation: the fresh-db-drift job (migrate deploy on a fresh DB) is the real test — it was skipped on prior PRs since they didn't touch prisma/**. Watching CI.

Migration-checksum note: this migration has been red in CI since it merged, so it has almost certainly never applied cleanly anywhere; any env that recorded it applied needs prisma migrate resolve before its next deploy.

🤖 Generated with Claude Code

…te stale workflow docs

Two pre-existing CI blockers (red on every PR; not introduced by recent work):

1. Migration 20260619120000_cost_layer_qty_precision_14_6 alters stock_movements.qty
   to numeric(14,6), but the stock_movements_reporting_evidence_guard constraint
   trigger (20260602103000) lists qty in its 'UPDATE OF ... qty ...' column set, so
   Postgres refuses: 'cannot alter type of a column used in a trigger definition'
   (P3018). prisma migrate deploy failed on a fresh DB, blocking the e2e-select,
   invariant-preflight, and schema-guardrails(fresh-db-drift) jobs entirely. Drop the
   trigger around the column rewrite and recreate it identically (the trigger function
   is unchanged). Only stock_movements.qty had a column-scoped trigger; the other
   widened columns have none.

2. docs/workflows.md was out of date, failing the docs-workflows check. Regenerated
   via npm run docs:workflows.

NOTE: editing an in-sequence migration changes its checksum. This migration has been
red in CI since it merged, so it has almost certainly never applied cleanly via
migrate deploy anywhere; on any environment that did record it applied, run
'prisma migrate resolve' before the next deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OneTwo3D
OneTwo3D force-pushed the ci/fix-migration-trigger-and-stale-workflow-docs branch from 50ad095 to 7c1d100 Compare June 21, 2026 16:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50ad095de6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ALTER COLUMN "quantity" TYPE numeric(14,6),
ALTER COLUMN "reservedQty" TYPE numeric(14,6);

-- The stock_movements_reporting_evidence_guard constraint trigger (migration

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the schema-scope marker so migration-scope passes

On a PR this still trips the schema guard before the fresh-db job: .github/workflows/schema-guardrails.yml runs node scripts/check-prisma-schema-scope.mjs "origin/${{ github.base_ref }}" HEAD, and that script fails any changed prisma/migrations/**/migration.sql without a matching prisma/schema.prisma change unless the migration file contains an exact -- prisma-schema-scope-ok: db-native... comment. I reproduced node scripts/check-prisma-schema-scope.mjs e510de09f37f7ae09e2174cb2ec030d62b947d43^ e510de09f37f7ae09e2174cb2ec030d62b947d43, and this migration is reported as unchecked, so the CI blocker remains for any PR carrying this trigger-only edit.

Useful? React with 👍 / 👎.

…QL (42P18)

The inventory invariant SQL collector passed STRANDED_TRANSFER_DAYS into a
jsonb_build_object value position without a type cast, so Postgres could not
determine the bind parameter's type ('could not determine data type of
parameter $42') and the whole inventory invariant report failed at runtime.
This surfaced only now that the migration fix lets invariant-preflight reach the
report on a fresh DB (unit tests use mocked clients, so the raw query never ran).
Cast it ::int, matching the same constant's already-cast use in the WHERE clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OneTwo3D
OneTwo3D merged commit a7f8984 into development Jun 21, 2026
11 of 12 checks passed
@OneTwo3D
OneTwo3D deleted the ci/fix-migration-trigger-and-stale-workflow-docs branch June 21, 2026 17:32
OneTwo3D pushed a commit that referenced this pull request Jun 21, 2026
OneTwo3D added a commit that referenced this pull request Jul 12, 2026
…or every connector mutation (q66in.4.6) (#471)

* chore(beads): sync cogs-audit state — scjz.52 fixed (25 total)

* fix(purchasing): diagnose BY_WEIGHT zero-freight on positive-qty zero-weight lines (cogs-audit scjz.17) (#309)

* fix(purchasing): diagnose BY_WEIGHT zero-freight on positive-qty zero-weight lines (cogs-audit scjz.17)

The equal-split fallback only fires when the whole BY_WEIGHT basis is zero. The
mixed case — some lines weight 0/null, others weighted — silently gave the
zero-weight positive-qty lines no freight (it landed entirely on the weighted
lines) with no warning. Weights are user-maintained and routinely 0/null, so
freight was quietly mis-distributed.

Emit a per-line diagnostic (new weight_zero_line warning code + activity log)
when a BY_WEIGHT split assigns zero freight to a positive-qty zero/blank-weight
line on the non-fallback path. Wired into all distribution sites:
computeGrossUnitCostBaseByLine (via an optional onWeightZeroLines callback) and
the recalc/direct-recalc paths (surfaced in result.warnings + audit run, via an
injectable warnWeightZeroLines dep). Non-destructive — allocation is unchanged;
this only surfaces the condition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(purchasing): make gross-cost zero-weight diagnostic opt-in (codex review on scjz.17)

computeGrossUnitCostBaseByLine also runs on read-only cost previews
(getPurchaseOrder, receipt validation), so defaulting onWeightZeroLines to a
persisted activity-log warning wrote side effects on every read/refresh. Only
invoke the callback when a caller explicitly passes it; the recalc paths still
surface the diagnostic via result.warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(purchasing): gate zero-weight diagnostic on positive freight amount (codex review on scjz.17)

The recalc loops recorded a weight_zero_line warning whenever a zero-weight
line existed on the non-fallback path, even for a BY_WEIGHT cost line with
amountBase <= 0 (zero or credit) where no freight was actually distributed away.
The gross path already skips non-positive amounts; gate the recalc warnings the
same way with decimal(freightCostLine.amountBase).gt(0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.17 fixed (26 total)

* fix(stock): lock stock_level row before cost reads in adjustment edit (cogs-audit scjz.3) (#310)

updateAdjustmentMovement pre-checked feasibility via unlocked aggregate reads and,
on the addition branch, computed getAverageUnitCost with no row lock, while
applyStockAdjustment locks the product+warehouse stock_level FOR UPDATE. A
concurrent consume between the read and the new layer's creation could cost the
layer against stale, uncommitted state (TOCTOU).

Extract the existing upsert + SELECT ... FOR UPDATE pattern into a shared
lockStockLevelRow helper and take it at the top of the edit transaction (before
currentLevel/aggregate/getAverageUnitCost reads), mirroring applyStockAdjustment.
applyStockAdjustment and applyOpeningStock now use the same helper.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.3 fixed (27 total)

* fix(stock): lock snapshot rows FOR UPDATE before JSON rewrite in revaluation (cogs-audit scjz.7) (#311)

updateSnapshotsForCostLayerChange SELECTed each snapshot row (no lock), patched
the costLayerSnapshot JSON array in app code, then UPDATEd the whole column. Two
concurrent revaluations of different cost layers that share the same snapshot row
(one row's array carries entries for both layers) each read the original array
and write back their own patch — last writer wins, silently dropping the other's
change.

Add FOR UPDATE to the row-finding SELECT so the second revaluation blocks until
the first commits, then re-reads the committed array. Stays on $queryRawUnsafe
(a row-returning query) so the lock is actually held — $executeRaw would not
(enforced by the row-lock-queryraw gate).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.7 fixed (28 total)

* fix(accounting): true up deferred revenue at fully-shipped terminal statuses (cogs-audit scjz.41) (#312)

* fix(accounting): true up deferred revenue at any fully-shipped terminal status (cogs-audit scjz.41)

The Group B remaining-deferred true-up fired only when order.status === 'SHIPPED'
on the final shipment. An order that ships its last units while already
COMPLETED / DELIVERED / PARTIALLY_REFUNDED only ever recognized the rounded
proportional slice, so rounding drift left pence of unearned revenue stranded in
deferral permanently.

Add a shared isFullyShippedTerminalStatus helper (SHIPPED, COMPLETED, DELIVERED,
PARTIALLY_REFUNDED — REFUNDED/CANCELLED excluded as full reversals) and gate the
true-up on it in both the Xero and QuickBooks daily-batch recognition loops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): exclude PARTIALLY_REFUNDED from deferred-revenue true-up (codex review on scjz.41)

PARTIALLY_REFUNDED can be set from pre-shipment states (refunding unshipped
lines) and already posts an UNEARNED_REV_REVERSAL that remainingDeferred does not
subtract, so truing up there would recognize the full original deferral and
over-debit unearned revenue. Narrow the fully-shipped terminal set to
SHIPPED/COMPLETED/DELIVERED; the refund-reversal-aware true-up for partially
refunded orders is tracked as scjz.68.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.41 fixed (29 total); scjz.68/.69 follow-ups

* fix(accounting): retry failed/missing FX revaluation reversals (cogs-audit scjz.39) (#313)

The unrealised-FX revaluation reverses each prior period's revaluation before
reposting at current rates. Two same-date guards stranded reversals: getPriorRevaluations
returned [] wholesale when any same-date revaluation existed, and runArApFxRevaluation
early-returned when hasRevaluationForDate. So if a prior reversal FAILED (FAILED is
excluded from ACTIVE_SYNC_STATUSES) but today's revaluation succeeded, the failed
reversal was never retried and the next period revalued on top, compounding the
unrealised FX P&L permanently.

The reversalSources set already only tracks ACTIVE reversals, so a prior with a
failed reversal is naturally eligible for retry once the blanket guards are gone:
- Drop the hasSameDateRevaluation short-circuit; always return strictly-earlier
  revaluations not covered by an active reversal (extracted to a pure, tested
  selectPriorRevaluationsToReverse helper).
- Replace the hasRevaluationForDate early-return with a flag: the reversal-retry
  loop always runs; only the fresh same-date revaluation queueing is skipped.

Re-queueing a failed reversal is safe: the accounting_sync_logs idempotency unique
index is partial on active statuses, so a FAILED row does not block a new PENDING
reversal, and active reversals still dedupe (no double-reverse).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.39 fixed (30 total)

* feat(accounting): invariant for posted revenue with a reversed payment (cogs-audit scjz.72) (#314)

* feat(accounting): invariant for posted revenue with a reversed payment and no credit note (cogs-audit scjz.72)

Part of scjz.42: a payment reversal (chargeback) on a manual order clears paidAt
but leaves the A1/A2/Group-B revenue posted, so revenue stays recognized with no
cash — and this was invisible to the invariant suite.

Add a critical 'revenue_posted_without_payment' invariant: an order with posted
revenue/allocation/shipment whose paidAt is explicitly null and which has no
compensating credit note. A1 deferral is only ever staged for a paid order, so a
cleared paidAt on a posted order is a strong uncompensated-chargeback signal.

paidAt is added to the loader select and made optional on the pure row type so
existing fixtures (paidAt undefined = unknown) don't fire — the check requires an
explicit null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): collect posted-but-unpaid orders regardless of retention window (codex review on scjz.72)

A payment reversed today on an order posted >6 months ago does not touch any
retained date (revenueDeferredDate/inventoryAllocatedDate/shipmentJournalDate/
refundedAt), so it would never be re-collected and the new
revenue_posted_without_payment invariant would miss it. Add an OR branch that
always collects { paidAt: null, revenueDeferredDate: { not: null } } — a narrow
set (chargebacks are rare).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): broaden posted-but-unpaid collection to A2/shipment (codex review on scjz.72)

Mirror the evaluator's trigger exactly: collect paidAt-null orders that have A1
OR A2 OR a posted shipment, so an anomalous A2-only or shipment-only order with a
reversed payment outside the retention window is still evaluated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): scope reversed-payment invariant to retention window to avoid false sync-evidence warnings (codex review on scjz.72)

The unwindowed posted-but-unpaid collection pulled old orders whose batch sync
logs are out of the (windowed) sync-log query, producing false
*_without_sync_evidence warnings. Revert to the windowed collection: the
revenue_posted_without_payment invariant now evaluates recent reversed payments
(the common case) cleanly. Out-of-window chargebacks are left to scjz.42's durable
handling (credit note / terminal status). Limitation documented inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): require credit note to cover posted revenue before suppressing finding (codex review on scjz.72)

A prior PARTIAL refund/credit note no longer suppresses revenue_posted_without_payment:
compare the summed credit-note totalBase against the order's posted revenue
(unearnedRevenueAmount). Only suppress when it fully covers (within 0.01). When the
posted amount is unknown (<= 0), fall back to presence to avoid false positives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): require durable credit-note id (not local number) to suppress finding (codex review on scjz.72)

creditNoteNumber is generated locally at refund creation before any sync, so a
never-synced/failed credit note would falsely suppress the very missing-accounting
case this invariant surfaces. Require accountingCreditNoteId (the external id),
matching the rest of the file's credit-note evidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.72 fixed (31 total); scjz.42 design + .70/.71 deferred

* feat(accounting): invariant that live journals balance debit==credit (cogs-audit scjz.38) (#315)

The invariant suite checked only that journal evidence existed, never the line
amounts, so an unbalanced journal (debit != credit) could reach the GL undetected.

Add a critical 'accounting_sync_journal_unbalanced' invariant: for every live
(PENDING/PROCESSING/SYNCED) sync log carrying journal lines, sum debits and
credits and flag a >0.005 mismatch. Metadata-only payloads (no lines, e.g.
daily-batch markers) and failed/unposted logs are skipped.

This is the first amount-level invariant; the remaining tie-outs (Group-B COGS ==
sum shipment COGS, inventory vs remaining-layer value, A1 deferred == Group-B
recognized) are tracked as follow-up sub-issues.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.38 balance-check slice merged (32 total); .73/.74/.75 follow-ups

* fix(manufacturing): value reserved WIP components (cogs-audit scjz.31) (#316)

* fix(manufacturing): value reserved not-yet-consumed WIP components at current cost (cogs-audit scjz.31)

getWipReport summed only PRODUCTION_OUT movement value as consumed component
value for IN_PROGRESS orders, but consumption posts at COMPLETED — so
in-progress WIP showed overhead + any partial consumption only, understating
reserved-but-not-consumed component value.

Value the still-to-be-consumed BOM requirement (qty*plannedQty net of any
already-posted PRODUCTION_OUT) at weighted-average current cost from open cost
layers, batched into one query per report to avoid an N+1 over components.
Surface as a new reservedComponentValueBase column + total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manufacturing): correct WIP reservation basis per Codex review (cogs-audit scjz.31)

Address three P2 findings on the initial WIP-reservation valuation:

1. Source reservations from the frozen componentSnapshot (the quantities
   actually reserved at IN_PROGRESS), falling back to the live BOM only when
   absent — matches how completion consumes, so a mid-production component edit
   no longer skews WIP.
2. Handle DISASSEMBLY separately: it reserves the finished good being broken
   down (outputProductId), not BOM components (which are recovered at
   completion). The component loop no longer values phantom disassembly stock.
3. Value reservations by walking open cost layers in FIFO order
   (receivedAt ASC, id ASC) up to the reserved qty, matching the cost
   completion capitalises, instead of a whole-pool weighted average.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manufacturing): deplete shared FIFO layers across WIP orders + pairwise layer fetch (cogs-audit scjz.31)

Round-2 Codex findings:
1. When multiple IN_PROGRESS orders reserve the same (product, warehouse), each
   was re-valued against the full layer list, so every row claimed the same
   cheap oldest quantity. consumeFifoReservation now mutates the shared layer
   pool, depleting it in the report's start-order (a proxy for completion
   order), so two 10-unit reservations over layers 10@1/10@100 report 10 + 1000.
2. Replace productId-IN x warehouseId-IN (which scans the cross product) with a
   pairwise OR over the actual reserved (product, warehouse) pairs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manufacturing): WIP legacy fallback uses live product components, not the Bom row (cogs-audit scjz.31)

Round-3 Codex finding: for IN_PROGRESS orders with a null componentSnapshot
(started before snapshots existed), the fallback read the legacy Bom row, but
completion falls back to outputProduct.productComponents — and product-component
edits update productComponents, not the old Bom. Align the report's fallback to
the same live productComponents the completion path consumes, so legacy WIP is
valued against the components completion will actually use. The Bom relation is
no longer selected in the WIP query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manufacturing): bound WIP cost-layer scan by source-row cap (cogs-audit scjz.31)

Round-4 Codex finding: the new cost-layer lookup had no take/overflow guard, so
a component with very many open FIFO layers could bypass the 50k source-row cap
the order/movement scans enforce and load arbitrary rows. Add take=LIMIT+1 and
throw ManufacturingAnalyticsSourceLimitError consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(manufacturing): surface reserved WIP value in WIP page + CSV export (cogs-audit scjz.31)

Round-5 Codex finding: wipValueBase now folds in reservedComponentValueBase, but
the WIP page table/summary and CSV export omitted it, so WIP/expected-output
totals no longer reconciled to the visible manufacturing + consumed columns. Add
a Reserved value column + summary stat to the page and the field to the CSV.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manufacturing): chunk WIP cost-layer pair query to avoid bind-param overflow (cogs-audit scjz.31)

Round-6 Codex finding: the pairwise OR adds two bind parameters per (product,
warehouse), so ~32k+ distinct reservations would overflow Postgres's parameter
limit before the 50k source-row cap was reached, failing otherwise-allowed
reports. Fetch layers in chunks of 1000 pairs (each pair wholly within one
chunk, preserving per-pair FIFO order) and enforce the row cap across chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync cogs-audit state — scjz.31 fixed (33 total)

* chore(beads): export churn

* chore(beads): P1 triage — defer .13/.34 (finance), .18/.21->epic zex3, .43->epic blq0 (allocation-pinned FIFO + revaluation event-log epics)

* fix(cost-layers): throw on copiedQty > source receivedQty instead of silent cost leak (cogs-audit scjz.28) (#317)

copyCostLayerSourceLinesProportionally capped ratio = copiedQty/receivedQty at 1
and only console.warn'd, so a caller copying more than the source's receivedQty
copied only 100% of the source cost into a larger target — understating the
target's cost composition (cost-value leak). All three callers (refund restock,
WMS transfer booked-in, Mintsoft stock sync) copy a SLICE drawn from the source
layer, so copiedQty can never legitimately exceed receivedQty; throw a clear
error to surface the upstream anomaly. The existing sub-µ rounding band above 1
still clamps to ratio 1.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.28 closed (#317)

* fix(cost-layers): tighten FIFO shortfall tolerance to engine scale (cogs-audit scjz.6) (#318)

* fix(cost-layers): tighten consumeFifoLayersStrict shortfall tolerance to engine scale (cogs-audit scjz.6)

The strict-consume guard threw only on remainingQty > 0.0001, silently absorbing
a real shortfall up to a ten-thousandth of a unit (large for fractional-unit
products). buildStockMovementValueFieldsFromConsumed then divides the consumed
value by the FULL rowQty — understating unit cost while claiming the qty fully
moved. Tighten to FIFO_SHORTFALL_TOLERANCE = 1e-6 (the 6dp engine scale) so only
float->Decimal noise is absorbed, and log any non-zero absorbed shortfall as a
reconciliation exception. The audit-snxr zero-consumed guard is unchanged; a
mid-band qty now trips the Insufficient-FIFO guard first (still a hard error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(stock-adjustment): sync FIFO feasibility precheck tolerance to 1e-6 (cogs-audit scjz.6)

Codex round-1 follow-up: the adjustment-edit feasibility precheck mirrored the
old 0.0001 FIFO tolerance. With strict consumption now rejecting >1e-6
shortfalls, a mid-band edit (e.g. 0.00005 short) passed the precheck then threw
later in consumeFifoLayersStrict, surfacing a misleading concurrent-consumption
error instead of the intended before-mutation availability message. Point the
mirror at QUANTITY_EPSILON (1e-6) so the two stay in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.6 closed (#318)

* fix(transfers): strict FIFO dispatch — honest comment + up-front cost-layer coverage check (cogs-audit scjz.4) (#319)

dispatchTransfer's comment claimed "tolerant mode (legacy stock may not have
layers)" but called consumeFifoLayersStrict, which hard-fails when the dispatched
qty isn't fully covered by cost layers. A source with positive stock_level but
insufficient cost layers passed the stock availability pre-check, then threw
mid-dispatch.

TRANSFER_OUT is a costed outbound movement, so strict consumption is correct
(un-layered positive stock is a stock/cost-layer desync to repair, not a
legitimate state). Corrected the comment, and added an advisory cost-layer
coverage check (isCostLayerCoverageSufficient, engine-scale 1e-6 tolerance
mirroring the strict consume) to the existing pre-flight loop so the desync is
surfaced early with an actionable message instead of failing after stock has been
decremented. The FOR UPDATE consume remains the authoritative guard.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.4 closed (#319)

* fix(purchasing): create freight PO + run initial landed-cost recalc atomically (cogs-audit scjz.15) (#320)

createFreightPo committed the freight PO + LandedCostLinks via db.purchaseOrder.create,
then ran recalculateLandedCosts in a SEPARATE db.$transaction. If the recalc threw
(linked primary CLOSED, lock timeout, etc.) the freight PO and links persisted but
the primaries were never uplifted and LandedCostLink.allocated stayed default —
'linked but unapplied' freight with no retry path.

Move the PO create into the same db.$transaction as the recalc so a recalc failure
rolls back the freight PO and its links together (matching the cancellation path,
which already recalcs in-tx). Post-commit work (revalidate, queue adjustment
journals, activity log) is unchanged and still runs on the committed po/landedResult.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.15 closed (#320)

* fix(cost-layers): preserve cogsBase on mixed shipment-snapshot presence (cogs-audit scjz.24) (#321)

* fix(cost-layers): preserve cogsBase on mixed shipment-snapshot presence (cogs-audit scjz.24)

refreshSalesOrderLineCogs summed COGS over ALL shipment lines for a sales line as
long as ANY had a snapshot (hasSnapshotByLineId). When a line had mixed presence
(one partial snapshotted, another's snapshot cleared), it wrote cogsBase = sum of
only the snapshotted subset — dropping the un-snapshotted partial's COGS,
understating COGS / overstating margin. The legacy-preservation guard only
covered the all-zero case.

Track snapshotted vs total shipment-line counts per sales line and, when mixed
(0 < snapshotted < total), preserve the prior cogsBase and log a reconciliation
exception rather than writing a partial total — consistent with the existing
legacy all-zero preservation. Full-coverage lines still sum; lines with no
shipment lines still null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cost-layers): mixed-snapshot guard only considers SHIPPED shipment lines (cogs-audit scjz.24)

Codex round-1 P1: the mixed-snapshot guard counted ALL shipment lines for a sales
line regardless of status, so a partial dispatch (one SHIPPED+snapshotted line +
one still PENDING/PICKING/PACKED without a snapshot yet) was wrongly treated as
mixed -> cogsBase left stale/null until every planned line shipped. Snapshots are
only written at dispatch (SHIPPED), so filter the shipment-line query to
shipment.status = 'SHIPPED'. Now only a SHIPPED line lacking a snapshot (a real
desync) trips the preserve path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.24 closed (#321)

* fix(accounting): reconcile Xero daily-batch digest-suffixed reference ids (cogs-audit scjz.37) (#322)

Live Xero daily-batch posting stamps a digest-suffixed referenceId
(buildDailyBatchReferenceId -> `<group>-<date>-<8 hex>`), but two consumers
matched the bare `<group>-<date>`:

1. recreateMissingDailyBatchLogs' hasLiveDailyBatchLog did an exact bare match, so
   it never found the live digest log and recreated a duplicate A1/A2/B batch
   (double-posting revenue + COGS). Now matches the bare key OR any
   `<bare>-<suffix>` variant.
2. The accounting invariant expected the bare key, so digest-suffixed live logs
   triggered shipment_posted_without_sync_evidence false-positives. The live-sync
   index now also indexes the digest-stripped key for daily-batch types.

QBO is bare end-to-end and remains consistent (strip is a no-op on bare keys).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.37 closed (#322)

* fix(valuation): flag live-valuation stock/cost-layer drift instead of always reporting reliable (cogs-audit scjz.44) (#323)

getOnHandAsOf's live path (asOf >= now) hard-set valueReplayReliable = true, so a
stock_levels-vs-cost_layers desync — including orphan layers (positive value, no
stock row) that still contribute to totals — was reported as fully reliable with
no warning.

loadCurrentState now also computes a currentValueDriftCount: the number of
(product, warehouse) pairs whose open cost-layer quantity diverges from the stock
quantity beyond engine-scale tolerance (covers orphan layers, missing layers, and
qty mismatches). Computed in both the raw SQL path (added SUM(remainingQty) as
layerQty) and the non-raw path. valueReplayReliable for live valuation is now
driftCount === 0, and the count is surfaced on the result. Replay/snapshot paths
are unchanged (driftCount 0; their reliability still comes from replay).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.44 closed (#323)

* chore(beads): defer remaining residue/human/staging-gated P2s (.51/.60/.61/.62/.64/.66/.70/.71) — open P1/P2 now only epics

* feat(cost-layers): cost-layer revaluation event log — schema + write path (cogs-audit blq0.1/.2) (#324)

* feat(cost-layers): cost-layer revaluation event log — schema + record helper (cogs-audit blq0.1)

Adds the cost_layer_revaluations table (additive migration) plus
recordCostLayerRevaluation() and updateCostLayerUnitCost() helpers. The log
captures every change to a cost layer's unitCostBase (old/new/effectiveAt/reason)
so as-of/historical valuation can reconstruct the basis valid at a point in time
instead of using the current post-revaluation cost (scjz.43/.48). No write sites
are wired yet (blq0.2) and no read path consumes it yet — purely additive
infrastructure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cost-layers): wire revaluation event log into all unitCostBase-change sites (cogs-audit blq0.2)

Records a CostLayerRevaluation at every site that mutates a cost layer's
unitCostBase, so the event log is populated for the changes it must audit
(Codex follow-up to blq0.1):
- landed-cost recalc (recalculateLandedCosts / recalculateDirectLandedCosts):
  one effectiveAt per run, reason 'landed_cost_recalc'.
- BOM-cascade output propagation (propagateLandedCostToOutputs): threads the run
  timestamp, reason 'landed_cost_output_propagation'.
- manufacturing cost recompute (updateManufacturingCostLines): reason
  'manufacturing_recompute'.
recordCostLayerRevaluation is injected via LandedCostServiceDeps so it's
stubbable; no-op deltas are skipped. The PO-line/source-line/qty-only updates
(fx rebase, source lines, adjustment restock, cancellation) are NOT layer
revaluations and are intentionally excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cost-layers): coalesce same-run cost-layer revaluations into one net event (cogs-audit blq0)

Codex P2: a diamond BOM cascade (or two revalued components feeding one output)
could record multiple revaluation events for the same layer with an identical
effectiveAt, leaving as-of replay unable to order them. recordCostLayerRevaluation
now coalesces a repeat for the same (costLayerId, effectiveAt) into a single net
old→final event (keeps the original old, advances new), dropping it entirely if
the net change is a no-op. Manufacturing recompute now captures one effectiveAt
per run too. Replay can order purely by effectiveAt with no intra-run sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): blq0.1/.2 closed (#324)

* feat(valuation): point-in-time reliability via revaluation log (cogs-audit scjz.43/.48) (#325)

* feat(valuation): flag point-in-time inaccuracy from later revaluations (cogs-audit scjz.43/.48)

Consumes the cost_layer_revaluations event log (blq0) to flag historical
valuation that draws on a cost basis revalued after the as-of date — the
resolution explicitly offered in scjz.43 ("flag valueReplayReliable=false when a
touched layer has a later revaluation"):

- getOnHandAsOf (scjz.43): historical paths now count in-scope cost-layer
  revaluations with effectiveAt > asOf (postAsOfRevaluationCount) and set
  valueReplayReliable=false when >0, so a snapshot/current-layer basis that
  reflects later landed-cost/manufacturing revaluations is reported unreliable
  instead of as point-in-time accurate. Live path unaffected.
- backfillInventorySnapshots (scjz.48): same root — backfilled values seed from
  current layers, so any revaluation after fromDate (postBackfillRevaluationCount)
  downgrades valueReplayReliable.

A full point-in-time value reconstruction remains a larger follow-up; flagging is
the issue-endorsed interim and mirrors the scjz.44 live-drift flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): use date-only half-open boundary for revaluation cutoff (cogs-audit scjz.43/.48)

Codex round-2: the post-asOf / post-backfill revaluation counts compared against
the `.999Z` / `> fromDate` proxy, mismatching the half-open date-only boundary
the rest of replay uses. A revaluation in the µs band above end-of-day, or during
the fromDate day itself (already in that day's EOD snapshot), was wrongly counted
as post-window. Both now use next-day-midnight (gte) for date-only as-of /
backfill fromDate, matching the replay upper bound; precise instants keep `gt`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): count post-asOf revaluations inside the current-reverse transaction (cogs-audit scjz.43)

Codex round-3: the current_reverse_replay path values from CURRENT cost layers
inside a serializable transaction, but postAsOfRevaluationCount was read on the
outer client beforehand — a revaluation committing in between would be valued in
yet reported reliable. Recompute the count on the transaction client for that
path (snapshot paths read immutable frozen data, so their outer count is fine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): scope revaluation flag to current-layer paths + reason-specific notices (cogs-audit scjz.43/.44)

Codex round-4:
1. A prior snapshot is frozen at/before asOf, so a later revaluation does not
   change its value — the snapshot_forward path no longer flags it. Only paths
   whose basis is affected flag: future_snapshot_reverse (revaluations in
   (asOf, futureSnapshotDate]) and current_reverse (revaluations after asOf).
2. Propagate currentValueDriftCount + postAsOfRevaluationCount through the
   inventory valuation + stock-on-hand report types, and split the as-of notice
   into reason-specific messages so a revaluation/drift cause is no longer
   misreported as "movements without value evidence".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): flag prior-snapshot revaluations in (snapshotDate, asOf] + stock-on-hand reason notices (cogs-audit scjz.43/.48)

Codex round-5:
1. snapshot_forward path now flags revaluations in (priorSnapshotDate, asOf] — the
   window the forward replay does NOT apply (movements carry value, not cost-layer
   revaluations), which also catches a backfilled snapshot that already holds a
   post-revaluation basis. (Revaluations after asOf still don't flag a pre-asOf
   snapshot.) This is the "revaluation check for snapshot-backed rows" Codex
   offered as the alternative to persisted snapshot reliability.
2. Stock-on-hand page notices are now reason-specific (missing-value/orphan vs
   post-asOf revaluation vs current drift) so users get the right explanation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(valuation): persist per-snapshot point-in-time reliability (cogs-audit scjz.43/.48)

Codex round-6: a backfilled snapshot can hold a post-revaluation value where the
revaluation is AFTER asOf — a read-time window can't detect that (can't tell a
live capture from a backfilled one). Persist reliability on the snapshot row:

- Schema: inventory_snapshots.valueReplayReliable (additive, default true).
- Backfill: sets it per day from the latest revaluation timestamp — day D is
  reliable iff next-day-midnight(D) > latest revaluation. Live/current captures
  write true.
- getOnHandAsOf: snapshot_forward and future_reverse now also count snapshot rows
  persisted as unreliable (countUnreliableSnapshots), so a stale backfilled basis
  is flagged even when no revaluation falls in the replay window.
- P3: revaluation notice reworded to "a later revaluation the as-of replay did not
  apply" (no longer claims strictly after the as-of date).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): mark backfilled days with reversed missing-value movements unreliable (cogs-audit scjz.43/.48)

Codex round-7: a snapshot day written after a null-totalValueBase movement was
reversed has that missing value baked in (it won't be replayed again), but the
per-row valueReplayReliable came only from the revaluation check. Require
missingValueMovementCount === 0 at write time too, so those days persist
valueReplayReliable=false and later as-of reads don't report them as reliable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): separate persisted stale-snapshot reason from post-asOf revaluation (cogs-audit scjz.43/.48)

Codex round-8 (reason separation): a snapshot persisted unreliable (backfilled
from a later basis OR with a missing-value movement baked in) was folded into
postAsOfRevaluationCount, so reports showed the "later revaluation" reason even
when no live revaluation applied. Split it into a distinct staleSnapshotCount with
its own notice, propagated through the inventory-valuation and stock-on-hand report
types/notices. valueReplayReliable now also requires staleSnapshotCount === 0.

Per-(product,warehouse) flag granularity (the other round-8 note) is filed as a P3
follow-up under blq0 — the current per-day flag is safe-side (over-flags
conservatively; never reports a wrong value as reliable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(valuation): scope backfill snapshot reliability per (product,warehouse) (cogs-audit scjz.43/.48)

Codex round-9: the day-level reliability flag was applied to every product's row,
so an unaffected SKU's snapshot persisted valueReplayReliable=false after any
revaluation/null-value movement on that day. Now per-pair: latest revaluation
per (product,warehouse) from costLayerRevaluation.findMany, and null-value
movements tracked per affected pair during the reverse walk. Only impacted SKUs'
rows are flagged; unrelated rows stay reliable. rowsFromState takes a per-row
reliability predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(valuation): bound backfill revaluation scan to the affected range (cogs-audit scjz.48)

Codex round-10: the per-pair revaluation findMany loaded the entire revaluation
log. Only revaluations on/after next-day-midnight(fromDate) can make any row in
[fromDate, toDate] stale (an earlier one is already reflected for every in-range
day), so filter effectiveAt >= nextDay(fromDate), matching the count query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): blq0 epic complete — .43/.48 resolved, event-log feature shipped (#324, #325)

* fix(sales): dispatch allocation-tagged snapshot + qty-based Allocated-Inventory relief (cogs-audit scjz.18/.21) (#326)

* fix(sales): relieve Allocated-Inventory on dispatch via allocation-tagged snapshot + qty-based release (cogs-audit scjz.18/.21)

Dispatch wrote shipment-line COGS snapshots with only {costLayerId,qty,unitCostBase},
and Group B relieved the per-allocation Allocated-Inventory contra by exact
costLayerId. Since dispatch consumes FIFO-oldest layers — which can differ from the
layers the allocation pinned — the contra was never relieved for shipped units
(scjz.18) and a layer mismatch stranded it (scjz.21).

- Dispatch (.18): decorate each snapshot entry with the line's orderAllocationId
  (one per line+warehouse+product), shipmentLineId, and source:'shipment', so the
  daily batch knows which allocation to relieve. COGS consumption is unchanged
  (still actual FIFO) — only the contra-relief accounting is fixed.
- Group B (.21): relieve allocationAvailability by QTY (reduceSnapshotByQty, FIFO
  within the allocation) instead of exact costLayerId, so a dispatch-vs-allocation
  layer mismatch still clears the contra. Applied in both Xero and QuickBooks
  daily-sync (cross-port).
- Also fixes a pre-existing shipment-service test-mock break from scjz.24's
  shipment.status='SHIPPED' filter (mock now handles equality + optional orderId).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sales): qty-based allocation relief across refund + daily-sync paths (cogs-audit scjz.21)

Codex round-1: making only the shipment relief qty-based diverged from the
still-costLayerId-based refund reliefs, so (P1) processRefundAccounting's
exact-layer shipment relief no-op'd and an unshipped refund could reverse
allocation cost for already-shipped units, and (P2) prior shipment/refund
deductions became order-dependent. Make ALL allocation-availability reductions
qty-based (reduceSnapshotByQty) — shipment→allocation and refund(allocation-source)
→allocation — in refund-service and both Xero/QuickBooks daily-syncs, so total
relieved qty is consistent and order-independent. Shipment-line availability and
the take-then-reduce build paths stay costLayerId (internally exact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): zex3 epic complete — .18/.21 resolved (#326); +24rl future point-in-time-recon task

* fix(accounting): daily-batch preview applies terminal true-up to match cron (cogs-audit scjz.69) (#327)

The Group B preview computed each shipment's revenue with the naive
per-shipment proportional formula and never trued up, so it under-reported
B revenue vs what the cron daily-sync actually posts: the cron groups a
batch's shipments by order and, on the final shipment of a fully-shipped
terminal order, recognizes the remaining deferred revenue instead of the
rounded proportional slice (scjz.41).

Extract the true-up/cap decision into a shared pure helper
recognizeShipmentRevenue() in revenue-recognition.ts and call it from both
daily-sync paths (Xero + QBO) and the preview, so the preview matches the
posted journal and the two cannot drift again. The preview now also groups
by order, carries recognizedPreviously from prior posted shipments, and uses
the same BOM-aware calculateCoverageByLine for shipment line values.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.69 closed (#327)

* feat(accounting): A1-deferred == Group-B-recognized lifecycle tie-out invariant (cogs-audit scjz.75) (#328)

Add an amount-level invariant: once an order is fully shipped (terminal
status) and every recognizable shipment has posted its Group-B revenue, the
sum of recognized revenue must tie out to the A1 deferred amount — the
terminal-status true-up exists to absorb rounding so it lands exactly. A
divergence beyond a penny means revenue is stranded in deferral or
over-recognized. Skips orders mid-recognition (a SHIPPED shipment still
awaiting its batch) and refunded orders (UNEARNED_REV_REVERSAL adjusts the
deferred base outside this sum — scjz.68 territory).

Also harden hasLiveSyncLog: referenceType is now required. The old optional
form, when omitted, matched a sync log of the right type+referenceId under
ANY referenceType, which could cross entity kinds. Every call site already
passes referenceType, so behaviour is unchanged for real callers; this just
removes the latent cross-entity-match footgun (scjz.75).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.75 closed (#328); defer scjz.73 (no per-shipment journal attribution) + scjz.74 (blocked on scjz.60 tolerance)

* fix(manufacturing): block direct DRAFT->COMPLETED (cogs-audit scjz.32) (#329)

A DRAFT production order has never been started, so it has neither a stock
reservation nor a frozen component snapshot (audit-H6 captures both at
IN_PROGRESS). Completing it directly fell back to the live BOM, consuming
whatever the bill of materials happened to be at completion time —
non-deterministic if the BOM was edited after creation, and not retry-safe.

The UI already requires Start Production before Mark Completed (NEXT_STATUS
offers DRAFT only Start/Cancel). Enforce the same on the server: completion
now requires IN_PROGRESS, guaranteeing the snapshot+reservation exist. The
live-BOM fallback in updateManufacturingOrderStatus now only covers legacy
orders started before audit-H6 added componentSnapshot.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.32 closed (#329), 7yw7 closed (already shipped); defer remaining COGS-audit tail (.9/.16/.22/.29/.30/.42/.53/.65/.67/.68 + 24rl) with rationale

* feat(accounting): canonical precision-policy module + GL-precision constant (cogs-audit scjz.60a) (#330)

* feat(accounting): canonical precision-policy module + GL-precision constant (cogs-audit scjz.60a)

Establish a single source of truth for the three base-currency precisions that
were scattered as magic 2/4/6 across the system:
- INVENTORY_COST_PRECISION (6dp) — cost layers / FIFO / cogs_entries / stock qty
- DOCUMENT_BASE_PRECISION  (4dp) — PO/SO line + invoice *Base totals
- GL_BASE_PRECISION        (2dp) — every base-currency GL posting

The module carries the policy as an in-code ADR: GL postings round to
GL_BASE_PRECISION (ROUND_HALF_UP), postings derive from the stored *Base value
(not foreign*rate), and the 6dp->2dp residue is reconciled explicitly (scjz.60c)
rather than silently accumulated; GL_LINE/JOURNAL_TOLERANCE bound the residue
treated as immaterial until then.

Wire both Xero and QBO daily-sync round2/round2Decimal to the constant so the
GL precision lives in exactly one place. Behaviour-identical (round2 was already
Math.round(v*100)/100); centralisation only. Unblocks the Group B rounding items
by giving each a documented contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): roundToGlPrecisionNumber honours ROUND_HALF_UP for negative ties (Codex P2 on scjz.60a)

Delegate the number helper to the Decimal path instead of Math.round, which
rounded negative ties toward +Infinity (-1.005 -> -1.00 instead of -1.01) and
diverged from round2Decimal/roundToGlPrecision. This would create penny
mismatches on reversal/credit GL postings that share the canonical helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(accounting): rounding-difference GL account mapping setting (Xero) (cogs-audit scjz.60b) (#331)

Add xero_rounding_difference_account setting key (type/keys/defaults) and surface
it in the account-mapping UI (ACCOUNT_FIELDS + save payload). The account is
OPTIONAL — deliberately not in REQUIRED_ACCOUNTS — so leaving it blank never
blocks sync; the posting logic (scjz.60c) only posts a rounding-difference line
when it is set, so behaviour is unchanged until configured.

Scoped to Xero (the live connector). The QBO equivalent is intentionally NOT
added here: the shared settings client is Xero-only for account mapping, so no
qbo_* account is UI-configurable today (Codex P2). The QBO rounding-difference
account lands with the QBO account-mapping UI — tracked as a follow-up.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.60a (#330) + scjz.60b (#331) closed; +follow-ups for save-payload omissions & QBO account UI

* feat(accounting): inventory GL<->cost-layer subledger reconciliation + tie-out invariant (cogs-audit scjz.60c-1 / scjz.74) (#332)

* feat(accounting): inventory GL <-> cost-layer subledger reconciliation + tie-out invariant (cogs-audit scjz.60c-1 / scjz.74)

Add the detection half of the rounding-difference reconciliation (scjz.60c) and
implement the deferred scjz.74 tie-out invariant, now unblocked by the scjz.60a
precision policy.

evaluateInventoryGlReconciliation (pure) classifies the gap between the 6dp
cost-layer subledger value (rounded to GL precision) and the GL inventory balance:
- balanced: ties out to the penny
- sweep:    within the rounding-scale sweep limit -> scjz.60c-2 will post the
            rounding-difference line for exactly this case
- flag:     beyond the limit -> a real discrepancy, surfaced as a CRITICAL
            invariant finding (inventory_gl_subledger_mismatch), NEVER swept, so
            the rounding account can't mask a genuine misstatement (the scjz.13 trap)

loadInventoryGlReconciliation reads the latest synced GL trial-balance snapshot
and the SQL subledger sum, degrading silently (available:false) when the inventory
account is unmapped or no snapshot exists yet. Surfaced in runAccountingInvariantReport.

The guarded rounding-difference JOURNAL POSTING is the follow-up scjz.60c-2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): reconcile as-of GL date + include Allocated Inventory (Codex P2 x2 on scjz.60c-1)

Both false-positive sources Codex flagged:
- Compare the subledger against Inventory + Allocated Inventory GL accounts. After
  Group A2 the daily batch moves value into the Allocated-Inventory contra while
  the cost layers still carry the stock until dispatch, so comparing against the
  Inventory account alone flagged every normal staged allocation.
- Value the subledger AS OF the GL snapshot's balanceDate from inventory_snapshots
  rather than summing live cost layers, so post-snapshot movements (purchases,
  shipments, adjustments, revaluations) no longer read as a mismatch.

Degrades gracefully (available:false, never blocks sync) when either GL account is
unmapped, the two account snapshots aren't on a common date, no inventory snapshot
exists for that date, or any inventory snapshot for that date is flagged not
point-in-time reliable (scjz.43) — so the as-of value is never trusted blindly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): sync Allocated Inventory GL balance by default (Codex P2 on scjz.60c-1)

The inventory GL reconciliation needs the Allocated-Inventory account balance on
the same date as Inventory, but syncXeroAccountBalanceSnapshots only fetched
Inventory + COGS by default, so the allocated snapshot was usually missing and
the reconciliation stayed permanently unavailable. Add xero_allocated_inventory_account
to the default synced account set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): synthesize zero snapshot for an omitted zero-balance account (Codex P2 on scjz.60c-1)

Requiring Allocated Inventory in the default snapshot set would 500 the whole
account-balance cron whenever that account is zero, because Xero omits
zero-balance accounts from the Trial Balance and the builder treated a missing
row as an error. A configured account is a real chart account, so an absent row
means a genuine zero balance: synthesize a zero snapshot instead. Guarded — if
NOTHING parsed (a fetch/parse failure), a missing row is still a real error, not
a fabricated zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): reconcile zero-stock dates instead of skipping (Codex P2 on scjz.60c-1)

inventory_snapshots is sparse (only non-zero pairs stored), so an empty result
for a date can mean genuinely-zero on-hand stock, not just missing evidence.
Returning no_subledger_snapshot in that case skipped exactly the scenario the
invariant must catch: a stale non-zero GL inventory/allocated balance on a
zero-stock date. Now treat empty as a zero subledger when the snapshot pipeline
demonstrably reached the date (a snapshot exists on/after it), so the mismatch
is flagged; only skip when there is no coverage evidence at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): require exact-date snapshot coverage before reconciling (Codex P2 on scjz.60c-1)

Value inventory_snapshots have no per-date run marker (unlike reservation
snapshots), so an empty result for a GL balance date is ambiguous — zero stock
or a missed snapshot — and a later snapshot only proves a later capture. The gte
coverage probe could therefore compare GL to a fabricated zero subledger on an
uncovered date and emit a false critical. Conservatively return unavailable when
the exact balanceDate has no rows. A follow-up adds an InventorySnapshotRun
coverage marker so genuinely zero-stock dates can reconcile too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): scjz.60c-1/.74 closed (#332); +follow-ups scjz.60c-2 (posting) and InventorySnapshotRun marker

* fix(ci): repair qty-precision migration trigger dependency + regenerate stale workflow docs (#333)

* fix(ci): repair qty-precision migration trigger dependency + regenerate stale workflow docs

Two pre-existing CI blockers (red on every PR; not introduced by recent work):

1. Migration 20260619120000_cost_layer_qty_precision_14_6 alters stock_movements.qty
   to numeric(14,6), but the stock_movements_reporting_evidence_guard constraint
   trigger (20260602103000) lists qty in its 'UPDATE OF ... qty ...' column set, so
   Postgres refuses: 'cannot alter type of a column used in a trigger definition'
   (P3018). prisma migrate deploy failed on a fresh DB, blocking the e2e-select,
   invariant-preflight, and schema-guardrails(fresh-db-drift) jobs entirely. Drop the
   trigger around the column rewrite and recreate it identically (the trigger function
   is unchanged). Only stock_movements.qty had a column-scoped trigger; the other
   widened columns have none.

2. docs/workflows.md was out of date, failing the docs-workflows check. Regenerated
   via npm run docs:workflows.

NOTE: editing an in-sequence migration changes its checksum. This migration has been
red in CI since it merged, so it has almost certainly never applied cleanly via
migrate deploy anywhere; on any environment that did record it applied, run
'prisma migrate resolve' before the next deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invariants): cast thresholdDays bind param in stranded-transfer SQL (42P18)

The inventory invariant SQL collector passed STRANDED_TRANSFER_DAYS into a
jsonb_build_object value position without a type cast, so Postgres could not
determine the bind parameter's type ('could not determine data type of
parameter $42') and the whole inventory invariant report failed at runtime.
This surfaced only now that the migration fix lets invariant-preflight reach the
report on a fresh DB (unit tests use mocked clients, so the raw query never ran).
Cast it ::int, matching the same constant's already-cast use in the WHERE clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): #333 merged (CI infra fixed); file P2 e2e-select stock-adjustment-save diagnosis

* chore(beads): scjz.20 deferred with kit-aware fulfilled-coverage redesign spec (7-round snapshot attempt reverted; wrong primitive); fold in scjz.20.1

* chore(beads): scjz.20 deferred again — both reject-based approaches target wrong condition (structural excess already cash-only; real bug is fulfilled-portion kit-unit consume throw, needs e2e repro)

* chore(beads): scjz.20 reproduction finding — kit-unit confusion in remainingShippedQtyByLine; repro needs runnable e2e (blocked by e2e-select stock-adjustment P2)

* fix(e2e): addStockAdjustment fills qty (step=1) + the new required unit-cost field (#334)

The 4 allocation e2e tests (regressions, sales-fulfillment, sales-management) failed
at addStockAdjustment waiting for '1 adjustment saved' that never appeared. Root
cause (reproduced against an isolated e2e DB): a unit-cost number field was added to
the bulk-adjustment dialog, so the helper's input[type=number].last() now filled
UNIT COST instead of qty — leaving qty empty ('Enter a non-zero quantity'), so the
save never persisted (stock_movements stayed 0). Target the qty field specifically
(step=1) and supply the now-required unit cost for the positive new-product line.

Not a production bug: the stock adjustment flow works; the helper selector was stale.
Verified all 4 previously-failing specs pass locally against an isolated ims_e2e DB.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sales): reverse kit refund COGS in component units (scjz.20) (#335)

Refund quantities are in SALES-LINE (kit) units, but shipment lines,
order allocations, and their cost-layer snapshots are denominated in
COMPONENT units (a KIT ships/allocates its expanded components). The
refund accounting staging mixed the two: shippedQtyByLine summed
component-unit shipment qtys and compared them to kit-unit line.qty,
and consumeShipment/AllocationCostForLine consumed `qty` kit units
directly from component-unit snapshots.

For a 1:2 kit, refunding N shipped kits reversed only N component units
of basis instead of N*2 — under-reversing COGS so inventory/GL could
never reconcile (repro: ship 3 kits / 6 components @ £10, refund →
£40 reversed instead of £60).

Fix:
- Load the fulfillment product graph and derive per-line component
  factors (component units per 1 sales-line unit; 1 for SIMPLE).
- Measure shipped qty as kit-equivalent COVERAGE via
  calculateCoverageByLine instead of a raw component-unit sum.
- consumeShipment/AllocationCostForLine now consume per component,
  reversing `qty * factor` of each component's basis. SIMPLE products
  (factor 1) are unchanged.
- Add productId to the allocation/shipment-line selects so the consume
  can match component rows.

Adds a kit regression test (extends the unit-test harness with
component-level shipment lines + a kit product graph). Validated
end-to-end against an isolated Postgres: COGS reversal £40 → £60.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(xero-settings): persist inventory-revaluation + manufacturing-overhead accounts (y87w) (#336)

handleSave()'s saveAccountingSettings payload explicitly enumerated account
keys but omitted xero_inventory_revaluation_account — even though it is
rendered as an editable field in ACCOUNT_FIELDS. Because saveXeroSettings
allowlists by XERO_SETTING_KEYS and upserts only the keys it receives,
editing Inventory Revaluation in the UI never persisted.

xero_manufacturing_overhead_account is a valid mapped setting key
(lib/accounting.ts maps it to manufacturingOverheadAccount) and is in the
allowlist, but was neither rendered nor saved.

Fix: render Manufacturing Overhead in ACCOUNT_FIELDS and add both keys to
the handleSave payload so both accounts are UI-configurable and persist.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(accounting): sweep inventory subledger-vs-GL rounding residue via outbox (scjz.60.4) (#337)

* feat(accounting): sweep inventory subledger-vs-GL rounding residue via outbox (scjz.60.4)

Follow-up to scjz.60c-1 (detection). The 6dp cost-layer subledger and the 2dp
GL inventory accounts accumulate a sub-penny rounding residue. Detection already
classifies the as-of gap as balanced/sweep/flag; this wires the `sweep` case to
actually post a balanced rounding-difference ManualJournal through the existing
Xero daily-batch outbox pipeline, so the inventory subledger ties to the GL.

- New AccountingSyncType DAILY_BATCH_INVENTORY_RECONCILIATION (+ enum migration,
  + queueXeroSync/queueQuickBooksSync unions, + sync-processor journal dispatch,
  + DAILY_BATCH_TYPES so failed sweeps are retried/recreated).
- End of the daily batch (after the core postings, so the snapshots it compares
  already reflect this run) loads the reconciliation and, when action==='sweep',
  enqueues the sweep journal. Idempotent per as-of date via hasLiveDailyBatchLog,
  so re-running the batch never double-posts. A failure here never aborts the
  batch — the core postings already committed.
- Guarded three ways: the rounding-difference account must be configured (its
  absence is the opt-out — residue accepted within tolerance, no line posted),
  the comparison must be available, and the gap must be pure rounding ('sweep').
  Material gaps ('flag') are surfaced by the reconciliation invariant, never swept.

The money logic (guard, sign — delta=subledger-GL drives DR/CR Inventory, balanced
DR/CR pair) is extracted into the pure, unit-tested buildInventoryReconciliationSweepJournal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(accounting): mirror the inventory reconciliation sweep into accounting events (scjz.60.4)

Codex P2: DAILY_BATCH_INVENTORY_RECONCILIATION posted to Xero but was absent
from MIRRORED_JOURNAL_ACCOUNTING_SYNC_TYPES, so the internal accounting_events
ledger would silently omit the rounding correction — leaving event-based audit/
reconciliation views inconsistent with the posted journal. Add it to the mirror
allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync issue state — close scjz.20, 7qua, y87w, scjz.60.4, scjz.60 (PRs #334-#337)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(accounting): InventorySnapshotRun coverage marker so zero-stock dates reconcile to GL (scjz.60.5) (#338)

* feat(accounting): InventorySnapshotRun coverage marker so zero-stock dates reconcile to GL (scjz.60.5)

inventory_snapshots is sparse, so an empty result for a GL balance date was
ambiguous (genuine zero on-hand vs. snapshot job missed the date) — the GL
reconciliation conservatively returned unavailable, skipping the date.

Mirror the InventoryReservationSnapshotRun pattern: add an InventorySnapshotRun
per-date coverage marker written by both the daily cron (source=cron) and the
historical backfill (source=backfill, skipped on dry-run). loadInventoryGl-
Reconciliation now treats empty snapshot rows as a genuine zero subledger when a
run marker exists for the exact date, so a stale non-zero GL on a zero-stock date
is flagged instead of silently skipped; with no marker it still degrades to
unavailable. Per-row valueReplayReliable still gates non-empty dates unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): sync issue export (scjz.60.5 claimed)

---------

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): close scjz.60.5 (PR #338)

* chore(beads): remember scjz.60.5 completion (PR #338)

* fix(manufacturing): write disassembly recovered-layer provenance in the equal-split path (scjz.27) (#339)

DISASSEMBLY completion wrote cost_layer_source_lines only when
baseAllocatedCost>0 AND totalRecoveredCostBase>0. In the equal-split-overhead
path (totalRecoveredCostBase==0, i.e. the disassembled output had zero cost),
the recovered component layers were created with a real overhead-only
unitCostBase but NO source-line provenance. landed-cost propagation discovers
downstream manufactured layers only via costLayerSourceLine.sourceCostLayerId,
so a later component landed-cost update that raises the output layer could never
reach those recovered layers — they stayed permanently understated.

Extract disassemblyProvenanceShare() (pure, unit-tested): proportional share
(baseAllocated/totalRecovered) when there is recovered cost, else an equal
1/componentCount share in the equal-split path (value 0/0). Recovered layers now
always record provenance; the source lines carry zero value at completion (so no
change to the initial unit cost and no spurious reclass — recompute already
equal-splits when base==0), and propagateLandedCostToOutputs fills in the real
value + layer unit cost when the output is later revalued.

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): close scjz.27 (#339), close .26/.61, create linkage+precision epics (4pz6/nnox)

* fix(accounting): drain consumed-portion freight through transit (scjz.34) (#340)

When a late landed cost arrived for goods ALREADY SOLD, the freight bill debited
the transit/clearing account for ALL received units, but the consumed (sold)
portion's retrospective COGS correction credited the inventory-revaluation P&L
account (audit-o3yb), NOT transit. The on-hand portion drained transit (DR
inventory / CR transit) but the sold units' freight share stayed in transit
permanently — transit never fully cleared the freight liability.

Route the consumed-portion COGS correction to the transit account (DR COGS / CR
transit on a cost increase), mirroring the on-hand reclass, so the freight bill's
transit debit drains in full. resolveConsumedCogsOffsetAccount now returns the
transit account; the inventory-revaluation account is no longer used for the
landed-cost path (retained as a configurable setting). Per the GL-treatment
decision on scjz.34 (accepts losing the separate inventory-revaluation P&L line
for sold-goods freight in exchange for a clean clearing-account reconciliation).

Co-authored-by: OneTwo3D IMS <dev@onetwo3d.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(beads): close scjz.34 (#340); scope deferred live-ledger tail into epics khdw/s5na/4wuu with recorded GL decisions

* fix(woocommerce): parse + accumulate order import money in Decimal (scjz.62) (#341)

The WC order import computed the order-level foreign-currency aggregates with
parseFloat + native `+`: parseFloat(line.total_tax) summed across shipping lines,
mappedLines[].taxForeign summed with `+`, parseFloat(wcOrder.total), and the
VAT-net subtotal via float division. These amounts feed the AR control and the FX
revaluation (amountForeign), so float drift (0.1+0.2-style) accrued across many
tax/line rows and was then locked in by /fxRate + round-4.

Extract two pure, unit-tested helpers in order-import.ts: parseWcMoney (Decimal
parse of WC money strings; empty/invalid -> 0, mirroring `parseFloat||0`) and
computeWcOrderForeignTotals (subtotal net of VAT/discount, tax, grand total — all
accumulated with addMoney in exact Decimal). subtotalForeign/taxForeign/totalForeign
are now Prisma.Decimal (the columns are @db.Decimal(18,4)) and flow into the single
/fxRate boundary; only the logActivity metadata converts via toNumber(). No behaviour
change beyond removing float drift; updateExistingWcOrderFr…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant