From a7243f80d741ad3470f30da99ab53ddfbdf73a2b Mon Sep 17 00:00:00 2001 From: OneTwo3D IMS Date: Tue, 23 Jun 2026 13:53:53 +0000 Subject: [PATCH 1/2] feat(qbo): cross-port payment-reversal detection + chargeback wiring (1ljl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pollQuickBooksPayments was forward-only (unpaid→paid) and never scanned for payment REVERSALS, so under QBO a reversed customer payment left the order paid with recognised revenue unreversed — the Xero poller's audit-M-acct #3 reversal detection (scjz.70/.71, PR #343) was never cross-ported. - classifyQboReversals + fetchReversedEntityIds: Balance>0 = payment un-applied (live invoice, chargeback-eligible), TotalAmt=0 = voided (QBO already reversed AR/revenue, skip chargeback to avoid double-reversal). Mirrors Xero's fetchReversedInvoiceIds {all, voided} contract. - Sales-reversal loop wires raiseChargebackForReversedOrder on revenueDeferredDate && !invoiceVoided; clears paidAt only after the chargeback is recorded (retry-safe). Bill-reversal loop clears paidAt with a WARNING. - Hold the poll watermark on a failed chargeback. Unlike Xero (cursor gate errors.length===0), QBO's gate is allQueriesSucceeded; without this the window advanced past the reversed invoice and the LastUpdatedTime>since reversal query never re-returned it, so the chargeback never actually retried. 5 unit tests for the pure classifier. type-check + lint + connector-fetch-boundaries + 58 accounting tests green. Live QBO sandbox validation still pending (Xero Demo can't exercise it). Part of epic onetwo3d-ims-4wuu. Co-Authored-By: Claude Opus 4.8 (1M context) --- .beads/issues.jsonl | 14 +- lib/connectors/quickbooks/payment-poller.ts | 170 +++++++++++++++++- tests/accounting/qbo-payment-reversal.test.ts | 43 +++++ 3 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 tests/accounting/qbo-payment-reversal.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8fe3f9b3..a16e4e63 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -70,7 +70,7 @@ {"_type":"issue","id":"onetwo3d-ims-0r8","title":"FIFO consumption race: candidate read happens before per-row lock","description":"lib/cost-layers.ts:62-95 — findMany reads the candidate cost-layer set, then takes per-row locks. Two concurrent shippers can read the same candidate set before either acquires a lock, then both decrement the same layer.\n\nImpact: physical stock corruption — over-consumption, negative remainingQty, broken FIFO ordering.\n\nVERIFY: confirm whether the lock is actually `SELECT ... FOR UPDATE` on the same row IDs and whether the surrounding $transaction is at Serializable isolation. If not, switch to Serializable or take the row locks before/inside the candidate read (e.g. `SELECT ... FOR UPDATE SKIP LOCKED`).","status":"closed","priority":1,"issue_type":"bug","assignee":"OneTwo3D IMS","owner":"dev@onetwo3d.com","created_at":"2026-04-25T09:57:52Z","created_by":"OneTwo3D IMS","updated_at":"2026-04-25T10:31:50Z","started_at":"2026-04-25T10:31:49Z","closed_at":"2026-04-25T10:31:50Z","close_reason":"Verified: not a bug. consumeFifoLayers (lib/cost-layers.ts:62-95) implements the textbook lock-then-re-read pattern: (1) findMany candidate IDs, (2) lockCostLayers acquires SELECT...FOR UPDATE on those IDs, (3) re-reads the locked rows via findMany on id IN [...] for fresh remainingQty values, (4) decrements based on the post-lock state. The explicit comment at line 69-70 documents this: 'Re-read after taking row locks so concurrent consumers cannot both act on the same pre-lock remainingQty snapshot and double-decrement a layer.' The agent missed the re-read.","labels":["code-review","concurrency","fifo","verify"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-4pz6.2","title":"Margin analytics: prorate revenue to in-window dispatched qty (scjz.51, 4pz6 phase 2a)","description":"Rewire getMarginAnalyticsReport to book revenue prorated to in-window dispatched qty per sales line via the new StockMovement.shipmentLineId linkage, instead of full line.totalBase against in-window COGS (period mismatch). Legacy unlinked dispatch residual distributed proportionally by line qty within order+product. Fixes scjz.51.","status":"closed","priority":2,"issue_type":"bug","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-23T11:01:22Z","created_by":"Jan","updated_at":"2026-06-23T11:59:12Z","started_at":"2026-06-23T11:01:32Z","closed_at":"2026-06-23T11:59:12Z","close_reason":"Merged PR #349 — margin revenue prorated to in-window dispatched qty (scjz.51); CI 9/9 + Codex clean","labels":["cogs-audit"],"dependencies":[{"issue_id":"onetwo3d-ims-4pz6.2","depends_on_id":"onetwo3d-ims-4pz6","type":"parent-child","created_at":"2026-06-23T11:01:22Z","created_by":"Jan","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-4pz6.1","title":"Durable StockMovement→ShipmentLine link + backfill (4pz6 phase 1)","description":"Promote the latent SALE_DISPATCH idempotency-key linkage (SALE_DISPATCH:shipmentLine:\u003cid\u003e) to a durable nullable StockMovement.shipmentLineId FK column, indexed. Write it at dispatch (shipment-service.ts). Backfill historical SALE_DISPATCH movements by extracting the id from idempotencyKey, FK-validated against existing shipment_lines. Foundation for rewiring .51 (margin period mismatch) and .67 (blended same-product revenue) at line granularity.","status":"closed","priority":2,"issue_type":"task","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-23T09:37:11Z","created_by":"Jan","updated_at":"2026-06-23T10:57:16Z","started_at":"2026-06-23T09:37:18Z","closed_at":"2026-06-23T10:57:16Z","close_reason":"Merged PR #348 — durable StockMovement.shipmentLineId link + backfill; CI 11/11 + Codex clean","labels":["cogs-audit"],"dependencies":[{"issue_id":"onetwo3d-ims-4pz6.1","depends_on_id":"onetwo3d-ims-4pz6","type":"parent-child","created_at":"2026-06-23T09:37:11Z","created_by":"Jan","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"onetwo3d-ims-1ljl","title":"QBO chargeback parity: sales payment-reversal detection + chargeback wiring","description":"The Xero chargeback (scjz.70/.71, PR #343) auto-raises a revenue-only chargeback credit note when a recognised order's payment is reversed in Xero. The QuickBooks connector has NO equivalent: pollQuickBooksPayments (lib/connectors/quickbooks/payment-poller.ts) returns only {salesPaid,billsPaid,errors} — it never scans for payment REVERSALS (the Xero poller's audit-M-acct #3 reversal detection was never cross-ported to QBO) and never calls raiseChargebackForReversedOrder. So under QBO a reversed customer payment leaves the order paid + revenue unreversed. SCOPE: (1) cross-port sales+bill payment-reversal detection to pollQuickBooksPayments (mirror lib/connectors/xero/payment-poller.ts: fetchReversedInvoiceIds equivalent via QBO API, detectPaymentReversals, clear paidAt with WARNING); (2) wire raiseChargebackForReversedOrder(orderId, {internalBypassToken}) into the QBO sales-reversal loop on revenueDeferredDate \u0026\u0026 !invoiceVoided, mirroring the Xero loop (retry-safe paidAt clear, voided skip). raiseChargebackForReversedOrder is connector-agnostic (it just stages the CREDIT_NOTE sync) so step 2 is small once step 1 exists. VALIDATION: needs a QBO sandbox connection (the Xero Demo can't exercise it). Found by Codex review of PR #343. Part of epic onetwo3d-ims-4wuu.","status":"open","priority":2,"issue_type":"feature","owner":"dev@onetwo3d.com","created_at":"2026-06-22T18:18:31Z","created_by":"Jan","updated_at":"2026-06-22T18:18:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"onetwo3d-ims-1ljl","title":"QBO chargeback parity: sales payment-reversal detection + chargeback wiring","description":"The Xero chargeback (scjz.70/.71, PR #343) auto-raises a revenue-only chargeback credit note when a recognised order's payment is reversed in Xero. The QuickBooks connector has NO equivalent: pollQuickBooksPayments (lib/connectors/quickbooks/payment-poller.ts) returns only {salesPaid,billsPaid,errors} — it never scans for payment REVERSALS (the Xero poller's audit-M-acct #3 reversal detection was never cross-ported to QBO) and never calls raiseChargebackForReversedOrder. So under QBO a reversed customer payment leaves the order paid + revenue unreversed. SCOPE: (1) cross-port sales+bill payment-reversal detection to pollQuickBooksPayments (mirror lib/connectors/xero/payment-poller.ts: fetchReversedInvoiceIds equivalent via QBO API, detectPaymentReversals, clear paidAt with WARNING); (2) wire raiseChargebackForReversedOrder(orderId, {internalBypassToken}) into the QBO sales-reversal loop on revenueDeferredDate \u0026\u0026 !invoiceVoided, mirroring the Xero loop (retry-safe paidAt clear, voided skip). raiseChargebackForReversedOrder is connector-agnostic (it just stages the CREDIT_NOTE sync) so step 2 is small once step 1 exists. VALIDATION: needs a QBO sandbox connection (the Xero Demo can't exercise it). Found by Codex review of PR #343. Part of epic onetwo3d-ims-4wuu.","notes":"Cross-port complete: classifyQboReversals + fetchReversedEntityIds (Balance\u003e0 = payment un-applied, TotalAmt=0 = voided) mirror Xero's fetchReversedInvoiceIds; sales-reversal loop wires raiseChargebackForReversedOrder on revenueDeferredDate \u0026\u0026 !invoiceVoided; bill-reversal loop clears paidAt. Adversarial pass found+fixed a QBO-specific bug: cursor gate is allQueriesSucceeded (not Xero's errors.length===0), so a failed chargeback advanced the watermark and the reversed invoice fell out of the next LastUpdatedTime\u003esince window — chargeback never retried. Fixed by setting allQueriesSucceeded=false on chargebackFailed. type-check + lint + connector-fetch-boundaries + 58 accounting tests green; 5 new unit tests for the classifier. REMAINING: live QBO sandbox validation (Xero Demo can't exercise it).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-22T18:18:31Z","created_by":"Jan","updated_at":"2026-06-23T13:53:40Z","started_at":"2026-06-23T13:08:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-4wuu","title":"[EPIC] Chargeback credit-notes + daily-batch recreate, Xero-sandbox-gated (scjz.70/.71/.36)","description":"DECISION (2026-06-22): wire up a Xero SANDBOX (Demo Company) on staging so these live-ledger items can be validated end-to-end before merge. Items: scjz.70 (revenue-only/no-restock chargeback mode on createSalesOrderRefund), scjz.71 (payment-poller raises the idempotent chargeback credit-note on reversal), scjz.36 (recreateMissingDailyBatchLogs double-post — mostly mitigated by #322/.37; residual = never mutate cogsBatchAmount on journaled shipments [shared with Epic D] or exclude COGS_REVERSAL-logged shipments from the B recreate). BLOCKED ON: Xero sandbox OAuth connection (interactive, user's Xero developer account). SANDBOX SETUP STEPS: (1) create/confirm a Xero Demo Company in the dev org; (2) point staging .env.local (/root/ims/onetwo3d-ims-isolated/.env.local) XERO_CLIENT_ID/SECRET at a sandbox app + set XERO_TOKEN_PATH to a sandbox token file; (3) run the OAuth connect flow against the Demo Company tenant (browser consent); (4) restart ims-stage-dev.service. Once connected I implement + validate .70/.71/.36 (and the Epic D cogsBatchAmount change) against the sandbox ledger.","design":"CHARGEBACK IMPLEMENTATION SPEC (traced 2026-06-22, ready to execute with the Xero Demo sandbox):\n\nKEY GL FINDING: the existing refund path (stageRefundAccountingReversals, lib/domain/sales/refund-service.ts) only reverses UNEARNED/deferred revenue — DR unearnedRevenueAccount / CR salesAccount (refund-service.ts:1217-1221) — plus COGS_REVERSAL (1198-1213) and allocation reversal (1223-1227). A chargeback on a REVENUE-RECOGNISED order must instead reverse RECOGNISED revenue against AR via the credit note (DR salesAccount / CR accountsReceivable), per the .42 design (recognised revenue lives in the AR invoice, not the A1/A2/B daily-batch journals which net to zero). This is NEW GL, not a flag-thread.\n\n.70 (refund-service mode):\n1. PURE helper buildChargebackRefundLines(orderLines, priorRefundQtyByLine) -\u003e RefundRequestLine[] for the FULL order at full REMAINING qty (order qty - already-refunded), totalBase = remaining proportional value. Unit-test it. (RefundRequestLine shape: refund-service.ts:99.)\n2. Add `chargeback?: boolean` to createSalesOrderRefund input (refund-service.ts:1294). When true:\n - SKIP restock: gate the applyReturnInboundStockTx block (refund-service.ts:1605-1620) on !chargeback.\n - SKIP COGS_REVERSAL: in stageRefundAccountingReversals, don't emit the COGS_REVERSAL sync (1198-1213) — cost stays as a loss.\n - REVERSE RECOGNISED REVENUE: emit a new credit-note/AR reversal for the recognised portion (needs a recognised-revenue amount per order [shipment.revenueRecognizedAmount, selected at 760] + an AR/accountsReceivable account in AccountingSettings — ADD if missing). For a still-deferred order, fall back to the existing unearned reversal.\n - RESET deferral markers (revenueDeferredDate/inventoryAllocatedDate) — already done for REFUNDED at 1178-1186.\n3. Unit-test the flag's effect on which accountingSyncs/inventory ops are emitted (the .70 acceptance).\n\n.71 (payment-poller wiring): in lib/connectors/xero/payment-poller.ts (sales reversal loop ~124-139) AND the QBO equivalent, when detectPaymentReversals fires on a manual order with posted revenue (revenueDeferredDate != null), invoke the chargeback createSalesOrderRefund(chargeback:true, full-order lines) with an idempotent replay key (accountingInvoiceId + reversal) so repeated polls don't duplicate. Load posting-state fields in the select. Cross-port Xero\u003c-\u003eQBO.\n\nVALIDATION MATRIX (Xero Demo Company): {recognised | still-deferred} x {full | partial prior-refund} x {Xero | QBO} — assert the credit note + AR/sales reversal posts, COGS is NOT reversed, no restock, deferral markers reset, idempotent on re-poll.","notes":"✅ CHARGEBACK .70/.71 MERGED to development (PR #343, squash 340fe2c, 2026-06-22). Xero revenue-only chargeback: on a recognised order's payment reversal the poller auto-raises an ACCRECCREDIT credit note that reverses recognised revenue (COGS kept as a loss, no restock, deferral markers reset, idempotent). EXHAUSTIVELY DEMO-VALIDATED on the Xero Demo Company across FOUR scenarios — non-taxable, taxable(20%), discounted-taxable(exclusive), discounted-taxable(inclusive) — each posting an exact AR/VAT/Sales/Discount reversal to zero. Order-level discounts MIRROR the invoice (full goods + a separate negative discount line to the discount account at the order-default tax code; gross→net conversion for inclusive orders), per user direction (not goods-scaling). 9 Codex adversarial rounds found+fixed: shipping-clamp over-credit, atomic idempotency, P1 unauthenticated-server-action, accounting-warning propagation (×2), shipped-but-unjournaled defer, discount tax-code handling, discount-account mirror, gross/net discount, prior-refund skip, retry lineKind. CI 12/12 green. STILL OPEN under this epic: scjz.36 (daily-batch recreate double-post) + the new QBO chargeback-parity follow-up (QBO has no payment-reversal detection — separate connector + needs a QBO sandbox).","status":"closed","priority":2,"issue_type":"epic","owner":"dev@onetwo3d.com","created_at":"2026-06-22T09:35:09Z","created_by":"Jan","updated_at":"2026-06-22T20:51:26Z","closed_at":"2026-06-22T20:51:26Z","close_reason":"All three named items merged to development: scjz.70/.71 chargeback (PR #343) + scjz.36 daily-batch recreate window (PR #344). QBO chargeback parity tracked separately as onetwo3d-ims-1ljl.","labels":["cogs-audit"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-s5na","title":"[EPIC] Refund/shipment posted-COGS basis (scjz.19/.22/.68)","description":"DECISION (2026-06-22): refunds reverse COGS at the ORIGINALLY-POSTED basis (not current carrying cost); restocked return layers stay at current carrying cost (decouple the two uses). KEY CONSTRAINT found: refreshShipmentCogsForCostLayerChange (cost-layers.ts:973) updates shipment.cogsBatchAmount to the NEW cost UNCONDITIONALLY — even when COGS_REVERSAL posting is disabled and the delta never posts — so neither cogsBatchAmount nor the shipment snapshot holds the posted basis. Correct fix = make cogsBatchAmount track the TRUE LEDGER COGS (gate its update on whether the posting actually occurs; advance only when a COGS_REVERSAL/daily-batch posting really happens), then reverse refunds proportional to it per shipment (refunded coverage qty / shipment total coverage qty x cogsBatchAmount). This is entangled with scjz.36 (cogsBatchAmount mutation on journaled shipments) and must be validated across the enable/disable x journaled/not x revalued matrix on the Xero sandbox. Includes scjz.22 (refund qty-cap vs COGS-basis source-of-truth) and scjz.68 (refund-reversal-aware deferred-rev true-up for PARTIALLY_REFUNDED).","notes":"scjz.19 (the core rationale) verified NON-ACTIONABLE (Codex-confirmed false premise: refund reverses at current cost correctly; same toggle gates landed + refund reversal). Epic's posted-COGS-basis premise is therefore MOOT. Remaining children .22 (refund qty-cap vs COGS-basis source-of-truth) and .68 (refund-reversal-aware deferred-rev true-up for PARTIALLY_REFUNDED) are SEPARATE concerns that must each be re-verified against current code (high false-positive rate in this audit) BEFORE any build. Do not build the cogsBatchAmount-tracks-ledger 'keystone' for .19 — it was predicated on the false premise.","status":"open","priority":2,"issue_type":"epic","owner":"dev@onetwo3d.com","created_at":"2026-06-22T09:35:09Z","created_by":"Jan","updated_at":"2026-06-22T10:25:20Z","labels":["cogs-audit"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-khdw","title":"[EPIC] Extend GL reconciliation sweep to COGS + transit accounts (scjz.13/.12)","description":"DECISION (2026-06-22): extend the scjz.60.4 guarded rounding-difference sweep (buildInventoryReconciliationSweepJournal + DAILY_BATCH_INVENTORY_RECONCILIATION) from the inventory subledger-vs-GL reconciliation to the COGS account (scjz.13) and the transit/clearing account (scjz.12), so the sub-penny 2dp-GL-vs-6dp-subledger residue ties out instead of accumulating. Sized because it needs: a GL balance snapshot for the COGS + transit accounts (mirroring account-balance-snapshots for inventory/allocated), a defined subledger 'truth' per account (COGS = cumulative 6dp cogs_entries; transit = freight-bill debits net of reclass credits), new guarded sweep journal types, and daily-batch wiring. Live daily-batch GL posting -\u003e validate on the Xero sandbox before merge. NOTE: the natural 'exact rounding' fix on scjz.13 was verified ACTIVELY HARMFUL; the sweep approach (post a small correcting journal to the rounding-difference account) is the safe path the user chose.","notes":"DEFERRED 2026-06-23 as a sized feature (decision: defer with spec). Both bug-children (.13/.12) are CLOSED as verified inherent sub-penny/sub-µ rounding. The epic's remaining deliverable = the transit-account reconciliation sweep. DESIGN SPEC: extend the scjz.60.4 inventory GL-reconciliation sweep (lib/domain/accounting/inventory-gl-reconciliation.ts — compares the GL account-balance snapshot vs a subledger value, sweeps residue within tolerance to the rounding-difference account, else flags) to the TRANSIT account (xero_transit_account / quickbooks equivalent). PREREQUISITE / OPEN FINANCE QUESTION: there is NO transit subledger today. The 'expected transit balance' (= freight received but not yet capitalized into inventory/COGS) must be DEFINED and computed from the landed-cost/freight-PO data (FREIGHT-type POs DR transit on receipt; landed-cost capitalization CRs transit). NOTE: the transit DR/CR journal posting was NOT found in landed-cost-service.ts during investigation — locate where the transit account is actually posted first. Then: snapshot the transit GL balance (account-balance-snapshots), compute the expected transit subledger value, reconcile + sweep the residue (mirroring inventory). Live-ledger; needs finance sign-off on the expected-balance basis + Demo validation. Value: clears the sub-µ .12 transit residue — modest; hence deferred for a dedicated effort.","status":"open","priority":2,"issue_type":"epic","owner":"dev@onetwo3d.com","created_at":"2026-06-22T09:35:08Z","created_by":"Jan","updated_at":"2026-06-23T07:00:39Z","labels":["cogs-audit"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -385,13 +385,13 @@ {"_type":"issue","id":"onetwo3d-ims-dvc","title":"dolt-share verification probe (delete me)","description":"If you can read this from staging clone, the dolt store is genuinely shared.","status":"closed","priority":4,"issue_type":"task","owner":"dev@onetwo3d.com","created_at":"2026-06-11T11:49:06Z","created_by":"Jan","updated_at":"2026-06-11T11:49:07Z","closed_at":"2026-06-11T11:49:07Z","close_reason":"dolt-share verified","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-9bd","title":"Add comment on module-load capture in defaultCancelPurchaseOrderDeps","description":"PR #159 introduced defaultCancelPurchaseOrderDeps in app/actions/purchase-orders.ts as a top-level const binding production implementations. Because it captures function references at module-load time, a future test framework that swaps module-level functions would not see the swap reflected through cancelPurchaseOrder default-deps callers. The project's tsx + explicit-injection style sidesteps this, but the gotcha is non-obvious.","acceptance_criteria":"defaultCancelPurchaseOrderDeps has a JSDoc-style comment explaining module-load capture and recommending explicit deps for test code.","status":"closed","priority":4,"issue_type":"task","owner":"info@onetwo3d.co.uk","created_at":"2026-06-11T11:40:45Z","created_by":"Jan","updated_at":"2026-06-11T11:41:30Z","closed_at":"2026-06-11T11:41:30Z","close_reason":"Fixed: defaultCancelPurchaseOrderDeps now documents module-load capture semantics and instructs tests to pass explicit deps when alternate behavior is needed.","labels":["code-review","docs","pr-159","purchasing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-5mp.3","title":"Document idempotency behaviour when fxRateToBase changes between requeues","description":"Concern surfaced in the Phase 1 Codex review.\n\naccountingPayloadKey() (app/actions/purchase-orders.ts:27) hashes the full payload as the idempotency key. With the new currencyRateToBase field included in the payload, a re-queue of the same document where the FX rate has changed produces a *different* hash, which means:\n\n- queue-level dedupe at lib/accounting.ts:157-169 misses (different key)\n- Xero idempotency at lib/connectors/xero/api.ts:110-112 is entry-id based, so it also won't dedupe\n\nIn theory this could allow a duplicate Xero post if the rate changed between the original queue and a retry. In practice rates don't change between retries (the rate was already stamped on the IMS document at creation time and we read it from there, not re-fetched), but the behaviour is non-obvious.\n\nAction:\n1. Document this in the unified-FX plan (docs/todo/unified-fx-rates-plan.md) under 'Open questions' or a new 'Idempotency' section.\n2. Decide: should accountingPayloadKey() exclude currencyRateToBase from the hash so a rate change doesn't produce a new key? OR should it be included so a deliberate re-stamp does produce a new post?\n3. Either way, add a code comment explaining the choice.\n\nLow priority — this is a 'nice to have clarification', not a known bug.","status":"closed","priority":4,"issue_type":"task","owner":"dev@onetwo3d.com","created_at":"2026-04-25T11:48:27Z","created_by":"OneTwo3D IMS","updated_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:41:28Z","close_reason":"Already satisfied on development: docs/todo/unified-fx-rates-plan.md documents document-stamped FX idempotency behavior, and tests/xero-fx.test.ts asserts accounting payload keys include currencyRateToBase.","labels":["connectors","fx","idempotency","woocommerce","xero"],"dependencies":[{"issue_id":"onetwo3d-ims-5mp.3","depends_on_id":"onetwo3d-ims-5mp","type":"parent-child","created_at":"2026-04-25T11:48:27Z","created_by":"OneTwo3D IMS","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"prisma-p2028-transaction-already-closed-a-rollback-cannot","value":"Prisma P2028 'Transaction already closed: A rollback cannot be executed on a committed transaction' on a StockMovement createMany is almost always a RED HERRING. The real cause is a DEFERRABLE INITIALLY DEFERRED constraint trigger (stock_movements_reporting_evidence_guard, migration 20260602103000) firing at COMMIT — createMany's batch transaction mis-surfaces the deferred RAISE EXCEPTION as P2028. To see the true error, reproduce the insert with a single db.stockMovement.create() instead of createMany: it surfaces the actual message (e.g. 'Outbound stock movement requires matching COGS evidence'). The guard requires every outbound movement (SALE_DISPATCH/PRODUCTION_OUT/outbound ADJUSTMENT) to have a cogs_entries row; it broke the forecasting historical import (zero-cost, warehouse-less SALE_DISPATCH, referenceType WcHistorical/CsvHistorical/WcInitialImport). Fixed in migration 20260616120000 by exempting exactly that shape, mirrored in lib/domain/inventory/invariants.ts requiresCogsEntryEvidence AND the SQL invariant collector (3 enforcement points must stay in sync). The app db client is a PrismaPg driver-adapter singleton over pg.Pool — after()/fire-and-forget was NOT the cause."} +{"_type":"memory","key":"local-e2e-environment-set-up-2026-06-21","value":"LOCAL E2E ENVIRONMENT (set up 2026-06-21, works): the unit-test mock can't model kit shipments, and DB-mutating prisma commands are NOT actually blocked — so run real e2e against an ISOLATED Postgres DB, never the shared live onetwo3d_ims_dev (live app is on :3000). SETUP: (1) imsdev role has CREATEDB; create DB once: PGPASSWORD=\u003cimsdev pw from .env\u003e psql -h127.0.0.1 -U imsdev -d onetwo3d_ims_dev -c 'CREATE DATABASE ims_e2e OWNER imsdev;'. (2) export DATABASE_URL=postgresql://imsdev:\u003cpw\u003e@localhost:5432/ims_e2e ; npx prisma migrate deploy (applies cleanly incl. the #333 trigger fix). (3) e2e needs SETTINGS_ENCRYPTION_KEY (only in /root/ims/onetwo3d-ims-isolated/.env.local), E2E_ROUTE_SECRET=e2e-route-secret (tests default to this), E2E_TEST_MODE=1, E2E_PORT=3001 (live app uses 3000), AUTH_SECRET (in .env). ISOLATION: inline env wins over .env (dotenv/Next don't override pre-set process.env) — verified the seed wrote to ims_e2e only. RUN: with those env vars, (chromium depends on the auth setup project automatically; reuseExistingServer reuses a manually-started \n\u003e onetwoinventory@2.0.0 dev\n\u003e next dev --port 3001\n\n\u001b[?25h). To see server-action errors, start the dev server manually (logs to a file) and run tests against it via E2E_BASE_URL=http://127.0.0.1:3001. db:seed:e2e seeds 3 warehouses (CBG/DEFAULT/E2E-SECOND) + admin@example.com/supplier@example.com."} +{"_type":"memory","key":"ims-staging-deploy-mechanism","value":"IMS staging (https://ims-stage.onetwo3d.co.uk, 10.0.3.99:3000) is run by the systemd service 'ims-stage-dev.service' as 'npm run dev' from /root/ims/onetwo3d-ims-isolated, with EnvironmentFile=/root/ims/onetwo3d-ims-isolated/.env.local (this is the ONLY place SETTINGS_ENCRYPTION_KEY and the correct AUTH_SECRET live; the app's plain .env lacks SETTINGS_ENCRYPTION_KEY). CORRECT deploy: (1) git pull --ff-only (it tracks origin/development) \u0026\u0026 npx prisma generate (if schema changed); (2) systemctl restart ims-stage-dev.service; (3) verify: systemctl is-active ims-stage-dev.service + curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/login == 200. Dev mode compiles from disk, so NO build step. DO NOT run scripts/deploy.sh on this box: it targets the WRONG dir (/root/ims/onetwo3d-ims) and runs 'npm start' without SETTINGS_ENCRYPTION_KEY/correct AUTH_SECRET, which grabs port 3000, blocks the systemd service's auto-restart, breaks reading encrypted settings (e.g. PO ordering throws 'SETTINGS_ENCRYPTION_KEY is required') and invalidates sessions (JWTSessionError 'no matching decryption secret' -\u003e users must re-login). The main checkout /root/ims/onetwo3d-ims is NOT the served instance. Both .env files point at the same DB onetwo3d_ims_dev@localhost."} +{"_type":"memory","key":"list","value":"list"} {"_type":"memory","key":"pr-review-bd-followup-push-target","value":"When filing bd issues as PR-review follow-ups, push the resulting .beads/issues.jsonl change to the SAME PR's branch (not to whatever branch the local working tree is on). Workflow: check out the PR's branch (gh pr checkout N), file bd issues, commit only .beads/issues.jsonl, push to the PR branch. This keeps review findings tied to the PR's history and avoids creating drive-by commits on unrelated feature branches."} +{"_type":"memory","key":"ims-staging-dev-cache-bloat","value":"IMS staging runs 'npm run dev' (Turbopack) via systemd ims-stage-dev.service. Its .next dev cache can BLOAT/corrupt (seen at 7.3GB) after many restarts+code pulls, causing the LARGEST route (/sync = Integrations, ~190KB of client components: sync-dashboard/sync-client/xero-client/mintsoft-client) to never finish compiling — symptom: journalctl shows '○ Compiling /sync ...' with NO 'Compiled'/no GET 200, page 'won't open', dev tools show continuous 'rendering'. type-check/lint pass (it's a Turbopack dev-compile/cache issue, not a code error). FIX: systemctl stop ims-stage-dev; rm -rf /root/ims/onetwo3d-ims-isolated/.next; systemctl start ims-stage-dev (first compile of each route is then slow ~3-5s but completes). Verify: journalctl shows 'GET /sync 200'. NOTE: a symlinked node_modules breaks Turbopack build/dev ('Symlink node_modules is invalid, points out of filesystem root') — worktree gates (tsc/eslint/node tests) tolerate the symlink but 'next build'/dev do NOT, so you can't repro a dev-compile issue in a node_modules-symlinked worktree. Longer-term: consider running staging as a production build (npm run build \u0026\u0026 npm start) instead of dev mode to avoid on-demand-compile cache fragility."} +{"_type":"memory","key":"prisma-p2028-transaction-already-closed-a-rollback-cannot","value":"Prisma P2028 'Transaction already closed: A rollback cannot be executed on a committed transaction' on a StockMovement createMany is almost always a RED HERRING. The real cause is a DEFERRABLE INITIALLY DEFERRED constraint trigger (stock_movements_reporting_evidence_guard, migration 20260602103000) firing at COMMIT — createMany's batch transaction mis-surfaces the deferred RAISE EXCEPTION as P2028. To see the true error, reproduce the insert with a single db.stockMovement.create() instead of createMany: it surfaces the actual message (e.g. 'Outbound stock movement requires matching COGS evidence'). The guard requires every outbound movement (SALE_DISPATCH/PRODUCTION_OUT/outbound ADJUSTMENT) to have a cogs_entries row; it broke the forecasting historical import (zero-cost, warehouse-less SALE_DISPATCH, referenceType WcHistorical/CsvHistorical/WcInitialImport). Fixed in migration 20260616120000 by exempting exactly that shape, mirrored in lib/domain/inventory/invariants.ts requiresCogsEntryEvidence AND the SQL invariant collector (3 enforcement points must stay in sync). The app db client is a PrismaPg driver-adapter singleton over pg.Pool — after()/fire-and-forget was NOT the cause."} +{"_type":"memory","key":"scjz-60-5-done-pr-338-2026-06","value":"scjz.60.5 DONE (PR #338, 2026-06-22): added InventorySnapshotRun per-date coverage marker (table inventory_snapshot_runs, mirrors InventoryReservationSnapshotRun) written by writeDailyInventorySnapshot (source=cron) + backfillInventorySnapshots per-day (source=backfill, skipped on dry-run); loadInventoryGlReconciliation now treats empty inventory_snapshots for the GL balance date as a genuine zero subledger (reconcile, flag stale GL) when a run marker exists, else still degrades to unavailable. Per-row valueReplayReliable gate for non-empty dates unchanged. Migration 20260622000000_inventory_snapshot_run hand-authored; all CI green incl. fresh-db-drift. Net: zero open actionable cogs-audit findings — only the scjz P1 epic container (deferred finance/staging tail) + iwrm P3 (QBO account UI, deferred-for-productization) remain."} {"_type":"memory","key":"standing-workflow-for-the-ims-cogs-audit-group","value":"STANDING WORKFLOW for the IMS COGS-audit group work (user directive 2026-06-21): pipeline PRs with CI. After pushing a PR + Codex-clean, do NOT block waiting for GitHub CI — start the next BD item immediately. When the prior PR's CI run finishes (watch in background), PAUSE the current item, return to that PR's CI results, fix any failures (CI is the real e2e/migration/invariant-preflight gate; local + Codex don't catch DB-level issues), then resume the group work. Always check 'gh pr checks \u003cN\u003e' on merged/just-pushed PRs — do not merge or move on assuming green."} {"_type":"memory","key":"dolt-store-shared-between-workspaces","value":"Both /root/ims/onetwo3d-ims (main) and /root/ims/onetwo3d-ims-isolated (staging) share ONE bd Dolt database. Staging clone's .beads/embeddeddolt is a symlink to /root/ims/onetwo3d-ims/.beads/embeddeddolt. This means: (1) bd create/update/close in either workspace is immediately visible from the other — no import roundtrip. (2) JSONL export in either workspace reflects the SAME state, so the PR #157 'wrong workspace re-export' trap no longer applies for these two checkouts. (3) PR review worktrees in /tmp/pr\u003cN\u003e still have their OWN .beads/embeddeddolt (from the PR branch's content) — they are deliberately separate. Setup commit: 5a4f57e ('chore(bd): make embeddeddolt gitignore match symlinks'). To rebuild from scratch after a fresh staging checkout: rm -rf /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt \u0026\u0026 ln -s /root/ims/onetwo3d-ims/.beads/embeddeddolt /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt"} -{"_type":"memory","key":"local-e2e-environment-set-up-2026-06-21","value":"LOCAL E2E ENVIRONMENT (set up 2026-06-21, works): the unit-test mock can't model kit shipments, and DB-mutating prisma commands are NOT actually blocked — so run real e2e against an ISOLATED Postgres DB, never the shared live onetwo3d_ims_dev (live app is on :3000). SETUP: (1) imsdev role has CREATEDB; create DB once: PGPASSWORD=\u003cimsdev pw from .env\u003e psql -h127.0.0.1 -U imsdev -d onetwo3d_ims_dev -c 'CREATE DATABASE ims_e2e OWNER imsdev;'. (2) export DATABASE_URL=postgresql://imsdev:\u003cpw\u003e@localhost:5432/ims_e2e ; npx prisma migrate deploy (applies cleanly incl. the #333 trigger fix). (3) e2e needs SETTINGS_ENCRYPTION_KEY (only in /root/ims/onetwo3d-ims-isolated/.env.local), E2E_ROUTE_SECRET=e2e-route-secret (tests default to this), E2E_TEST_MODE=1, E2E_PORT=3001 (live app uses 3000), AUTH_SECRET (in .env). ISOLATION: inline env wins over .env (dotenv/Next don't override pre-set process.env) — verified the seed wrote to ims_e2e only. RUN: with those env vars, (chromium depends on the auth setup project automatically; reuseExistingServer reuses a manually-started \n\u003e onetwoinventory@2.0.0 dev\n\u003e next dev --port 3001\n\n\u001b[?25h). To see server-action errors, start the dev server manually (logs to a file) and run tests against it via E2E_BASE_URL=http://127.0.0.1:3001. db:seed:e2e seeds 3 warehouses (CBG/DEFAULT/E2E-SECOND) + admin@example.com/supplier@example.com."} {"_type":"memory","key":"pr-review-bd-export-trap","value":"When filing bd follow-ups on a PR branch in a worktree, NEVER trust 'bd export' to give you a JSONL that respects the PR's pending closures — the worktree's bd uses its OWN .beads/embeddeddolt (from the branch), so bd commands in the worktree work against that workspace. The PR #157 trap (lost 49 closures) happened because I ran bd commands in the MAIN checkout, then copied its JSONL onto the worktree, overwriting the PR's closures. Safe workflows: (a) edit the PR branch's .beads/issues.jsonl DIRECTLY via Python/sed to append new issue rows — DO NOT call bd export; (b) work entirely in the worktree's bd (cd /tmp/pr\u003cN\u003e \u0026\u0026 bd create ...) so the export reflects PR state + your additions naturally; (c) the post-PR-#157 fix-up commit was fa5d214. See also dolt-store-shared-between-workspaces — that fix shares the dolt store across the main+staging checkouts but worktrees stay independent on purpose."} -{"_type":"memory","key":"scjz-60-5-done-pr-338-2026-06","value":"scjz.60.5 DONE (PR #338, 2026-06-22): added InventorySnapshotRun per-date coverage marker (table inventory_snapshot_runs, mirrors InventoryReservationSnapshotRun) written by writeDailyInventorySnapshot (source=cron) + backfillInventorySnapshots per-day (source=backfill, skipped on dry-run); loadInventoryGlReconciliation now treats empty inventory_snapshots for the GL balance date as a genuine zero subledger (reconcile, flag stale GL) when a run marker exists, else still degrades to unavailable. Per-row valueReplayReliable gate for non-empty dates unchanged. Migration 20260622000000_inventory_snapshot_run hand-authored; all CI green incl. fresh-db-drift. Net: zero open actionable cogs-audit findings — only the scjz P1 epic container (deferred finance/staging tail) + iwrm P3 (QBO account UI, deferred-for-productization) remain."} -{"_type":"memory","key":"ims-staging-dev-cache-bloat","value":"IMS staging runs 'npm run dev' (Turbopack) via systemd ims-stage-dev.service. Its .next dev cache can BLOAT/corrupt (seen at 7.3GB) after many restarts+code pulls, causing the LARGEST route (/sync = Integrations, ~190KB of client components: sync-dashboard/sync-client/xero-client/mintsoft-client) to never finish compiling — symptom: journalctl shows '○ Compiling /sync ...' with NO 'Compiled'/no GET 200, page 'won't open', dev tools show continuous 'rendering'. type-check/lint pass (it's a Turbopack dev-compile/cache issue, not a code error). FIX: systemctl stop ims-stage-dev; rm -rf /root/ims/onetwo3d-ims-isolated/.next; systemctl start ims-stage-dev (first compile of each route is then slow ~3-5s but completes). Verify: journalctl shows 'GET /sync 200'. NOTE: a symlinked node_modules breaks Turbopack build/dev ('Symlink node_modules is invalid, points out of filesystem root') — worktree gates (tsc/eslint/node tests) tolerate the symlink but 'next build'/dev do NOT, so you can't repro a dev-compile issue in a node_modules-symlinked worktree. Longer-term: consider running staging as a production build (npm run build \u0026\u0026 npm start) instead of dev mode to avoid on-demand-compile cache fragility."} -{"_type":"memory","key":"ims-staging-deploy-mechanism","value":"IMS staging (https://ims-stage.onetwo3d.co.uk, 10.0.3.99:3000) is run by the systemd service 'ims-stage-dev.service' as 'npm run dev' from /root/ims/onetwo3d-ims-isolated, with EnvironmentFile=/root/ims/onetwo3d-ims-isolated/.env.local (this is the ONLY place SETTINGS_ENCRYPTION_KEY and the correct AUTH_SECRET live; the app's plain .env lacks SETTINGS_ENCRYPTION_KEY). CORRECT deploy: (1) git pull --ff-only (it tracks origin/development) \u0026\u0026 npx prisma generate (if schema changed); (2) systemctl restart ims-stage-dev.service; (3) verify: systemctl is-active ims-stage-dev.service + curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/login == 200. Dev mode compiles from disk, so NO build step. DO NOT run scripts/deploy.sh on this box: it targets the WRONG dir (/root/ims/onetwo3d-ims) and runs 'npm start' without SETTINGS_ENCRYPTION_KEY/correct AUTH_SECRET, which grabs port 3000, blocks the systemd service's auto-restart, breaks reading encrypted settings (e.g. PO ordering throws 'SETTINGS_ENCRYPTION_KEY is required') and invalidates sessions (JWTSessionError 'no matching decryption secret' -\u003e users must re-login). The main checkout /root/ims/onetwo3d-ims is NOT the served instance. Both .env files point at the same DB onetwo3d_ims_dev@localhost."} -{"_type":"memory","key":"list","value":"list"} diff --git a/lib/connectors/quickbooks/payment-poller.ts b/lib/connectors/quickbooks/payment-poller.ts index e9329e0a..69b7e823 100644 --- a/lib/connectors/quickbooks/payment-poller.ts +++ b/lib/connectors/quickbooks/payment-poller.ts @@ -6,6 +6,8 @@ import { db } from '@/lib/db' import { logActivity } from '@/lib/activity-log' +import { INTERNAL_ACTION_BYPASS } from '@/lib/internal-action-bypass' +import { detectPaymentReversals } from '@/lib/domain/accounting/payment-reversal' import { qboQuery } from './api' import { getSettingValue } from '@/lib/settings-store' @@ -27,14 +29,62 @@ type QboQueryResponse = { QueryResponse: Record } +type QboEntityId = { Id: string } + +/** + * Split the QBO transactions that regressed out of the fully-paid state into the + * full reversed set and the subset that was VOIDED. Mirrors the Xero poller's + * {all, voided} contract (audit-M-acct #3 / scjz.71): + * - balanceDueEntities: invoices/bills whose Balance returned to > 0 (the payment + * was deleted/un-applied but the document is still live) — eligible for a + * revenue chargeback on the sales side. + * - voidedEntities: invoices/bills QBO zeroed out (TotalAmt = 0). QBO has already + * reversed their AR/revenue, so paidAt is cleared but NO chargeback is raised + * (a separate credit note would double-reverse). + * Pure set union so it can be unit-tested without the QBO API. + */ +export function classifyQboReversals( + balanceDueEntities: QboEntityId[], + voidedEntities: QboEntityId[], +): { all: Set; voided: Set } { + const all = new Set() + const voided = new Set() + for (const e of balanceDueEntities) all.add(e.Id) + for (const e of voidedEntities) { + all.add(e.Id) + voided.add(e.Id) + } + return { all, voided } +} + +// QBO equivalent of Xero's fetchReversedInvoiceIds. An IMS-paid document (Balance +// was 0) is "reversed" if, modified since the last poll, its QBO transaction now +// has Balance > 0 (payment removed) or TotalAmt = 0 (voided/zeroed). Returns null +// if either query failed so the caller can hold the poll watermark and retry. +async function fetchReversedEntityIds( + entity: 'Invoice' | 'Bill', + since: string, +): Promise<{ all: Set; voided: Set } | null> { + const [balanceRes, voidedRes] = await Promise.all([ + qboQuery>(entity, `Balance > '0' AND MetaData.LastUpdatedTime > '${since}'`), + qboQuery>(entity, `TotalAmt = '0' AND MetaData.LastUpdatedTime > '${since}'`), + ]) + if (!balanceRes.ok || !voidedRes.ok) return null + const balanceDue = balanceRes.data?.QueryResponse?.[entity] ?? [] + const voided = voidedRes.data?.QueryResponse?.[entity] ?? [] + return classifyQboReversals(balanceDue, voided) +} + /** * Poll QuickBooks for paid invoices and bills. * Updates paidAt on matching IMS records and advances order status. */ -export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; billsPaid: number; errors: string[] }> { +export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; billsPaid: number; salesReversed: number; billsReversed: number; errors: string[] }> { const errors: string[] = [] let salesPaid = 0 let billsPaid = 0 + let salesReversed = 0 + let billsReversed = 0 let allQueriesSucceeded = true const lastPoll = await getSettingValue(LAST_POLL_KEY) @@ -97,6 +147,85 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil } } + // --- Sales payment reversals (audit-M-acct #3 / scjz.70/.71) --- + // Forward poll only marks unpaid→paid. If an invoice IMS thinks is paid no longer + // has a zero balance in QBO — payment deleted/un-applied (Balance > 0) or the + // invoice voided (TotalAmt = 0) — clear paidAt so IMS stops showing it paid. + // Status is NOT auto-reverted (the order may already be picking/shipped); a + // WARNING carrying the current status flags it. Must run AFTER the forward pass + // so a pay-then-reverse within one window nets to the correct (unpaid) state. + const paidOrders = await db.salesOrder.findMany({ + where: { + accountingInvoiceId: { not: null }, + paidAt: { not: null }, + shoppingLinks: { none: {} }, + }, + select: { + id: true, + accountingInvoiceId: true, + orderNumber: true, + externalOrderNumber: true, + status: true, + revenueDeferredDate: true, + }, + }) + + if (paidOrders.length > 0) { + const reversedIds = await fetchReversedEntityIds('Invoice', since) + if (!reversedIds) { + allQueriesSucceeded = false + errors.push('Failed to query QuickBooks invoices for payment reversals') + } else { + for (const order of detectPaymentReversals(paidOrders, reversedIds.all)) { + // scjz.71: a reversed payment on a revenue-POSTED order (revenue recognised + + // invoiced) is a chargeback — raise a revenue-only credit note that reverses + // recognised revenue against AR. Idempotent (one chargeback per order). + // A VOIDED invoice has already had its AR/revenue reversed by QBO, so a + // separate credit note would double-reverse — only auto-chargeback an + // un-applied payment where the invoice is still live. + // CRITICAL: clear paidAt ONLY after the chargeback is recorded — otherwise a + // failed chargeback would drop the order out of the next poll's paidOrders + // (paidAt: not null) and the recognised revenue would never be reversed. + const invoiceVoided = order.accountingInvoiceId != null && reversedIds.voided.has(order.accountingInvoiceId) + let chargebackFailed = false + if (order.revenueDeferredDate && !invoiceVoided) { + try { + const { raiseChargebackForReversedOrder } = await import('@/app/actions/sales') + const chargeback = await raiseChargebackForReversedOrder(order.id, { internalBypassToken: INTERNAL_ACTION_BYPASS }) + if (chargeback.error) { + chargebackFailed = true + errors.push(`Chargeback for order ${order.orderNumber ?? order.id} failed: ${chargeback.error}`) + } + } catch (chargebackError) { + chargebackFailed = true + errors.push(`Chargeback for order ${order.orderNumber ?? order.id} failed: ${String(chargebackError)}`) + } + } + // Leave paidAt set on a failed chargeback so the reversal is re-attempted and + // the order is not silently shown unpaid-and-unreversed. Also hold the poll + // watermark: unlike Xero (whose cursor gate is errors.length===0), the QBO + // cursor advances on allQueriesSucceeded, so without this the window moves past + // the reversed invoice and the LastUpdatedTime>since reversal query never + // re-returns it — the chargeback would never actually retry. + if (chargebackFailed) { + allQueriesSucceeded = false + continue + } + await db.salesOrder.update({ where: { id: order.id }, data: { paidAt: null } }) + salesReversed++ + await logActivity({ + entityType: 'SALES_ORDER', + entityId: order.id, + action: 'payment_reversal_detected', + tag: 'sync', + level: 'WARNING', + description: `Payment no longer present in QuickBooks for order ${order.orderNumber ?? order.externalOrderNumber} (status: ${order.status}) — cleared paidAt. Review whether the order status should revert.`, + resolveUser: false, + }) + } + } + } + // --- Purchase bills (vendor payments) --- const unpaidBills = await db.purchaseInvoice.findMany({ where: { @@ -135,6 +264,37 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil } } + // --- Purchase bill payment reversals (audit-M-acct #3) --- + // A bill IMS thinks paid whose QBO transaction regressed (Balance > 0, payment + // un-applied; or TotalAmt = 0, voided) gets paidAt cleared with a WARNING. No + // chargeback equivalent on the purchase side. + const paidBills = await db.purchaseInvoice.findMany({ + where: { accountingInvoiceId: { not: null }, paidAt: { not: null } }, + select: { id: true, accountingInvoiceId: true, poId: true, po: { select: { reference: true, status: true } } }, + }) + + if (paidBills.length > 0) { + const reversedIds = await fetchReversedEntityIds('Bill', since) + if (!reversedIds) { + allQueriesSucceeded = false + errors.push('Failed to query QuickBooks bills for payment reversals') + } else { + for (const bill of detectPaymentReversals(paidBills, reversedIds.all)) { + await db.purchaseInvoice.update({ where: { id: bill.id }, data: { paidAt: null } }) + billsReversed++ + await logActivity({ + entityType: 'PURCHASE_ORDER', + entityId: bill.poId, + action: 'bill_payment_reversal_detected', + tag: 'sync', + level: 'WARNING', + description: `Bill payment no longer present in QuickBooks for PO ${bill.po.reference} (PO status: ${bill.po.status}) — cleared paidAt.`, + resolveUser: false, + }) + } + } + } + // Only advance the poll watermark if all QBO queries succeeded. // If a query failed, keep the previous checkpoint so the next run // replays the missed window instead of permanently skipping payments. @@ -146,15 +306,15 @@ export async function pollQuickBooksPayments(): Promise<{ salesPaid: number; bil }) } - if (salesPaid > 0 || billsPaid > 0) { + if (salesPaid > 0 || billsPaid > 0 || salesReversed > 0 || billsReversed > 0) { await logActivity({ entityType: 'SYSTEM', action: 'quickbooks_payment_poll', tag: 'sync', - description: `QuickBooks payment poll: ${salesPaid} sales payment(s), ${billsPaid} bill payment(s) detected`, - metadata: { salesPaid, billsPaid }, + description: `QuickBooks payment poll: ${salesPaid} sales paid, ${billsPaid} bills paid, ${salesReversed} sales reversed, ${billsReversed} bills reversed`, + metadata: { salesPaid, billsPaid, salesReversed, billsReversed }, }) } - return { salesPaid, billsPaid, errors } + return { salesPaid, billsPaid, salesReversed, billsReversed, errors } } diff --git a/tests/accounting/qbo-payment-reversal.test.ts b/tests/accounting/qbo-payment-reversal.test.ts new file mode 100644 index 00000000..76dddfad --- /dev/null +++ b/tests/accounting/qbo-payment-reversal.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { classifyQboReversals } from '@/lib/connectors/quickbooks/payment-poller' +import { detectPaymentReversals } from '@/lib/domain/accounting/payment-reversal' + +test('balance-due entities are reversed but not voided (chargeback-eligible)', () => { + const { all, voided } = classifyQboReversals([{ Id: 'I1' }, { Id: 'I2' }], []) + assert.deepEqual([...all].sort(), ['I1', 'I2']) + assert.equal(voided.size, 0) +}) + +test('zeroed entities are both reversed and voided (chargeback-skipped)', () => { + const { all, voided } = classifyQboReversals([], [{ Id: 'I3' }]) + assert.ok(all.has('I3')) + assert.ok(voided.has('I3')) +}) + +test('an entity that is both balance-due and zeroed lands in voided (union, deduped)', () => { + const { all, voided } = classifyQboReversals([{ Id: 'I4' }], [{ Id: 'I4' }]) + assert.deepEqual([...all], ['I4']) + assert.ok(voided.has('I4')) +}) + +test('empty inputs yield empty sets', () => { + const { all, voided } = classifyQboReversals([], []) + assert.equal(all.size, 0) + assert.equal(voided.size, 0) +}) + +test('classifier output drives detectPaymentReversals over IMS-paid orders', () => { + const paidOrders = [ + { id: 'o1', accountingInvoiceId: 'I1' }, // payment un-applied -> reversed + { id: 'o2', accountingInvoiceId: 'I3' }, // voided -> reversed (skip chargeback) + { id: 'o3', accountingInvoiceId: 'I9' }, // untouched -> not reversed + ] + const { all, voided } = classifyQboReversals([{ Id: 'I1' }], [{ Id: 'I3' }]) + const reversed = detectPaymentReversals(paidOrders, all) + assert.deepEqual(reversed.map((o) => o.id).sort(), ['o1', 'o2']) + // I1 is live (chargeback-eligible); I3 is voided (chargeback-skipped) + assert.equal(voided.has('I1'), false) + assert.equal(voided.has('I3'), true) +}) From 563166c80c3075369a16a578a4232cc7317d36d0 Mon Sep 17 00:00:00 2001 From: OneTwo3D IMS Date: Tue, 23 Jun 2026 13:55:50 +0000 Subject: [PATCH 2/2] chore(beads): link QBO sandbox-validation follow-up (vz6q) to 1ljl/PR #351 Co-Authored-By: Claude Opus 4.8 (1M context) --- .beads/issues.jsonl | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a16e4e63..e674b331 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -68,6 +68,7 @@ {"_type":"issue","id":"onetwo3d-ims-5oh","title":"Allocation snapshots editable between A2 staging and Group B run","description":"resetAllocationAccountingIfStaged() only triggers when shipmentJournalDate is set — i.e. after Group B has stamped the order. Between A2 staging and Group B, allocations can be edited and the next batch will use stale snapshots. Shipment-line snapshots are also not cleared on reset (only allocation snapshots are).\n\nRefs:\n- app/actions/allocation.ts:42, :74-77\n\nFix direction: lock allocations on A2 staging and require an explicit unstage that clears both allocation- and shipment-line snapshots.","status":"closed","priority":1,"issue_type":"bug","assignee":"OneTwo3D IMS","owner":"dev@onetwo3d.com","created_at":"2026-04-25T09:57:53Z","created_by":"OneTwo3D IMS","updated_at":"2026-04-25T10:17:44Z","started_at":"2026-04-25T10:17:43Z","closed_at":"2026-04-25T10:17:44Z","close_reason":"Verified: not a bug. resetAllocationAccountingIfStaged (allocation.ts:42-79) is called inside every allocation-mutation tx (e.g. deallocateOrder line 937). When inventoryAllocatedDate is set: (a) blocks edits if any shipment has shipmentJournalDate (Group B already ran) — line 55-64; (b) otherwise clears inventoryAllocatedDate AND OrderAllocation.costLayerSnapshot so A2 rebuilds on next batch. ShipmentLine snapshots are intentionally NOT cleared because they reflect the actual SHIPPED state and should be preserved. The 'stale snapshots in next batch' claim is incorrect.","labels":["code-review","cogs","sync-xero"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-jgi","title":"StockLevel.quantity drifts from sum of cost-layer remainingQty","description":"StockLevel.quantity is mutated directly in several places and is not derived from the cost layers. Partial transaction failures or layer adjustments can leave the two out of sync. There is no reconciliation job.\n\nRefs:\n- lib/stock.ts:166-172, :193-197 (direct StockLevel mutations)\n- lib/transfers.ts:558-559 (transfer mutations)\n- lib/cost-layers.ts:181-197 (layer mutations not synced back)\n\nFix direction: either make quantity a derived view (sum of layers) or add a reconciliation job + alert when they diverge.","status":"closed","priority":1,"issue_type":"bug","assignee":"OneTwo3D IMS","owner":"dev@onetwo3d.com","created_at":"2026-04-25T09:57:53Z","created_by":"OneTwo3D IMS","updated_at":"2026-04-25T10:17:45Z","started_at":"2026-04-25T10:17:44Z","closed_at":"2026-04-25T10:17:45Z","close_reason":"Verified: not a real bug. StockLevel.quantity and cost-layer sums are mutated together inside the same Prisma $transaction at every site (allocation.ts:1303-1310 + 1327-1329, manufacturing.ts:684-705 + 722-738 + 780-798, transfers.ts:405-414 + 556-578, stock.ts:689-756, purchase-orders.ts receipts). Postgres ACID prevents partial commits. Drift is only possible via out-of-band DB writes, which no app fix can prevent. A reconciliation job would be defensive observability, not a bug fix — open a separate feature request if desired.","labels":["code-review","fifo","inventory"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-0r8","title":"FIFO consumption race: candidate read happens before per-row lock","description":"lib/cost-layers.ts:62-95 — findMany reads the candidate cost-layer set, then takes per-row locks. Two concurrent shippers can read the same candidate set before either acquires a lock, then both decrement the same layer.\n\nImpact: physical stock corruption — over-consumption, negative remainingQty, broken FIFO ordering.\n\nVERIFY: confirm whether the lock is actually `SELECT ... FOR UPDATE` on the same row IDs and whether the surrounding $transaction is at Serializable isolation. If not, switch to Serializable or take the row locks before/inside the candidate read (e.g. `SELECT ... FOR UPDATE SKIP LOCKED`).","status":"closed","priority":1,"issue_type":"bug","assignee":"OneTwo3D IMS","owner":"dev@onetwo3d.com","created_at":"2026-04-25T09:57:52Z","created_by":"OneTwo3D IMS","updated_at":"2026-04-25T10:31:50Z","started_at":"2026-04-25T10:31:49Z","closed_at":"2026-04-25T10:31:50Z","close_reason":"Verified: not a bug. consumeFifoLayers (lib/cost-layers.ts:62-95) implements the textbook lock-then-re-read pattern: (1) findMany candidate IDs, (2) lockCostLayers acquires SELECT...FOR UPDATE on those IDs, (3) re-reads the locked rows via findMany on id IN [...] for fresh remainingQty values, (4) decrements based on the post-lock state. The explicit comment at line 69-70 documents this: 'Re-read after taking row locks so concurrent consumers cannot both act on the same pre-lock remainingQty snapshot and double-decrement a layer.' The agent missed the re-read.","labels":["code-review","concurrency","fifo","verify"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"onetwo3d-ims-vz6q","title":"Live QBO sandbox validation for payment-reversal + chargeback parity (1ljl/PR #351)","description":"PR #351 cross-ported Xero payment-reversal detection + revenue chargeback to pollQuickBooksPayments. Pure-logic unit tests pass, but the path has NOT been exercised against a live QuickBooks sandbox (the Xero Demo company can't drive QBO). Validate against a QBO sandbox connection: (1) un-apply a customer payment on a recognised invoice → confirm Balance\u003e0 detection clears paidAt AND raises a revenue-only chargeback credit note; (2) void an invoice → confirm TotalAmt=0 detection clears paidAt and SKIPS the chargeback (no double-reversal); (3) un-apply a bill payment → confirm paidAt cleared + WARNING; (4) force a chargeback failure → confirm watermark held and reversal retried next poll. Parent epic onetwo3d-ims-4wuu.","status":"open","priority":2,"issue_type":"task","owner":"dev@onetwo3d.com","created_at":"2026-06-23T13:55:34Z","created_by":"Jan","updated_at":"2026-06-23T13:55:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-4pz6.2","title":"Margin analytics: prorate revenue to in-window dispatched qty (scjz.51, 4pz6 phase 2a)","description":"Rewire getMarginAnalyticsReport to book revenue prorated to in-window dispatched qty per sales line via the new StockMovement.shipmentLineId linkage, instead of full line.totalBase against in-window COGS (period mismatch). Legacy unlinked dispatch residual distributed proportionally by line qty within order+product. Fixes scjz.51.","status":"closed","priority":2,"issue_type":"bug","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-23T11:01:22Z","created_by":"Jan","updated_at":"2026-06-23T11:59:12Z","started_at":"2026-06-23T11:01:32Z","closed_at":"2026-06-23T11:59:12Z","close_reason":"Merged PR #349 — margin revenue prorated to in-window dispatched qty (scjz.51); CI 9/9 + Codex clean","labels":["cogs-audit"],"dependencies":[{"issue_id":"onetwo3d-ims-4pz6.2","depends_on_id":"onetwo3d-ims-4pz6","type":"parent-child","created_at":"2026-06-23T11:01:22Z","created_by":"Jan","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-4pz6.1","title":"Durable StockMovement→ShipmentLine link + backfill (4pz6 phase 1)","description":"Promote the latent SALE_DISPATCH idempotency-key linkage (SALE_DISPATCH:shipmentLine:\u003cid\u003e) to a durable nullable StockMovement.shipmentLineId FK column, indexed. Write it at dispatch (shipment-service.ts). Backfill historical SALE_DISPATCH movements by extracting the id from idempotencyKey, FK-validated against existing shipment_lines. Foundation for rewiring .51 (margin period mismatch) and .67 (blended same-product revenue) at line granularity.","status":"closed","priority":2,"issue_type":"task","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-23T09:37:11Z","created_by":"Jan","updated_at":"2026-06-23T10:57:16Z","started_at":"2026-06-23T09:37:18Z","closed_at":"2026-06-23T10:57:16Z","close_reason":"Merged PR #348 — durable StockMovement.shipmentLineId link + backfill; CI 11/11 + Codex clean","labels":["cogs-audit"],"dependencies":[{"issue_id":"onetwo3d-ims-4pz6.1","depends_on_id":"onetwo3d-ims-4pz6","type":"parent-child","created_at":"2026-06-23T09:37:11Z","created_by":"Jan","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-1ljl","title":"QBO chargeback parity: sales payment-reversal detection + chargeback wiring","description":"The Xero chargeback (scjz.70/.71, PR #343) auto-raises a revenue-only chargeback credit note when a recognised order's payment is reversed in Xero. The QuickBooks connector has NO equivalent: pollQuickBooksPayments (lib/connectors/quickbooks/payment-poller.ts) returns only {salesPaid,billsPaid,errors} — it never scans for payment REVERSALS (the Xero poller's audit-M-acct #3 reversal detection was never cross-ported to QBO) and never calls raiseChargebackForReversedOrder. So under QBO a reversed customer payment leaves the order paid + revenue unreversed. SCOPE: (1) cross-port sales+bill payment-reversal detection to pollQuickBooksPayments (mirror lib/connectors/xero/payment-poller.ts: fetchReversedInvoiceIds equivalent via QBO API, detectPaymentReversals, clear paidAt with WARNING); (2) wire raiseChargebackForReversedOrder(orderId, {internalBypassToken}) into the QBO sales-reversal loop on revenueDeferredDate \u0026\u0026 !invoiceVoided, mirroring the Xero loop (retry-safe paidAt clear, voided skip). raiseChargebackForReversedOrder is connector-agnostic (it just stages the CREDIT_NOTE sync) so step 2 is small once step 1 exists. VALIDATION: needs a QBO sandbox connection (the Xero Demo can't exercise it). Found by Codex review of PR #343. Part of epic onetwo3d-ims-4wuu.","notes":"Cross-port complete: classifyQboReversals + fetchReversedEntityIds (Balance\u003e0 = payment un-applied, TotalAmt=0 = voided) mirror Xero's fetchReversedInvoiceIds; sales-reversal loop wires raiseChargebackForReversedOrder on revenueDeferredDate \u0026\u0026 !invoiceVoided; bill-reversal loop clears paidAt. Adversarial pass found+fixed a QBO-specific bug: cursor gate is allQueriesSucceeded (not Xero's errors.length===0), so a failed chargeback advanced the watermark and the reversed invoice fell out of the next LastUpdatedTime\u003esince window — chargeback never retried. Fixed by setting allQueriesSucceeded=false on chargebackFailed. type-check + lint + connector-fetch-boundaries + 58 accounting tests green; 5 new unit tests for the classifier. REMAINING: live QBO sandbox validation (Xero Demo can't exercise it).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Jan","owner":"dev@onetwo3d.com","created_at":"2026-06-22T18:18:31Z","created_by":"Jan","updated_at":"2026-06-23T13:53:40Z","started_at":"2026-06-23T13:08:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -385,13 +386,13 @@ {"_type":"issue","id":"onetwo3d-ims-dvc","title":"dolt-share verification probe (delete me)","description":"If you can read this from staging clone, the dolt store is genuinely shared.","status":"closed","priority":4,"issue_type":"task","owner":"dev@onetwo3d.com","created_at":"2026-06-11T11:49:06Z","created_by":"Jan","updated_at":"2026-06-11T11:49:07Z","closed_at":"2026-06-11T11:49:07Z","close_reason":"dolt-share verified","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-9bd","title":"Add comment on module-load capture in defaultCancelPurchaseOrderDeps","description":"PR #159 introduced defaultCancelPurchaseOrderDeps in app/actions/purchase-orders.ts as a top-level const binding production implementations. Because it captures function references at module-load time, a future test framework that swaps module-level functions would not see the swap reflected through cancelPurchaseOrder default-deps callers. The project's tsx + explicit-injection style sidesteps this, but the gotcha is non-obvious.","acceptance_criteria":"defaultCancelPurchaseOrderDeps has a JSDoc-style comment explaining module-load capture and recommending explicit deps for test code.","status":"closed","priority":4,"issue_type":"task","owner":"info@onetwo3d.co.uk","created_at":"2026-06-11T11:40:45Z","created_by":"Jan","updated_at":"2026-06-11T11:41:30Z","closed_at":"2026-06-11T11:41:30Z","close_reason":"Fixed: defaultCancelPurchaseOrderDeps now documents module-load capture semantics and instructs tests to pass explicit deps when alternate behavior is needed.","labels":["code-review","docs","pr-159","purchasing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"onetwo3d-ims-5mp.3","title":"Document idempotency behaviour when fxRateToBase changes between requeues","description":"Concern surfaced in the Phase 1 Codex review.\n\naccountingPayloadKey() (app/actions/purchase-orders.ts:27) hashes the full payload as the idempotency key. With the new currencyRateToBase field included in the payload, a re-queue of the same document where the FX rate has changed produces a *different* hash, which means:\n\n- queue-level dedupe at lib/accounting.ts:157-169 misses (different key)\n- Xero idempotency at lib/connectors/xero/api.ts:110-112 is entry-id based, so it also won't dedupe\n\nIn theory this could allow a duplicate Xero post if the rate changed between the original queue and a retry. In practice rates don't change between retries (the rate was already stamped on the IMS document at creation time and we read it from there, not re-fetched), but the behaviour is non-obvious.\n\nAction:\n1. Document this in the unified-FX plan (docs/todo/unified-fx-rates-plan.md) under 'Open questions' or a new 'Idempotency' section.\n2. Decide: should accountingPayloadKey() exclude currencyRateToBase from the hash so a rate change doesn't produce a new key? OR should it be included so a deliberate re-stamp does produce a new post?\n3. Either way, add a code comment explaining the choice.\n\nLow priority — this is a 'nice to have clarification', not a known bug.","status":"closed","priority":4,"issue_type":"task","owner":"dev@onetwo3d.com","created_at":"2026-04-25T11:48:27Z","created_by":"OneTwo3D IMS","updated_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:41:28Z","close_reason":"Already satisfied on development: docs/todo/unified-fx-rates-plan.md documents document-stamped FX idempotency behavior, and tests/xero-fx.test.ts asserts accounting payload keys include currencyRateToBase.","labels":["connectors","fx","idempotency","woocommerce","xero"],"dependencies":[{"issue_id":"onetwo3d-ims-5mp.3","depends_on_id":"onetwo3d-ims-5mp","type":"parent-child","created_at":"2026-04-25T11:48:27Z","created_by":"OneTwo3D IMS","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"local-e2e-environment-set-up-2026-06-21","value":"LOCAL E2E ENVIRONMENT (set up 2026-06-21, works): the unit-test mock can't model kit shipments, and DB-mutating prisma commands are NOT actually blocked — so run real e2e against an ISOLATED Postgres DB, never the shared live onetwo3d_ims_dev (live app is on :3000). SETUP: (1) imsdev role has CREATEDB; create DB once: PGPASSWORD=\u003cimsdev pw from .env\u003e psql -h127.0.0.1 -U imsdev -d onetwo3d_ims_dev -c 'CREATE DATABASE ims_e2e OWNER imsdev;'. (2) export DATABASE_URL=postgresql://imsdev:\u003cpw\u003e@localhost:5432/ims_e2e ; npx prisma migrate deploy (applies cleanly incl. the #333 trigger fix). (3) e2e needs SETTINGS_ENCRYPTION_KEY (only in /root/ims/onetwo3d-ims-isolated/.env.local), E2E_ROUTE_SECRET=e2e-route-secret (tests default to this), E2E_TEST_MODE=1, E2E_PORT=3001 (live app uses 3000), AUTH_SECRET (in .env). ISOLATION: inline env wins over .env (dotenv/Next don't override pre-set process.env) — verified the seed wrote to ims_e2e only. RUN: with those env vars, (chromium depends on the auth setup project automatically; reuseExistingServer reuses a manually-started \n\u003e onetwoinventory@2.0.0 dev\n\u003e next dev --port 3001\n\n\u001b[?25h). To see server-action errors, start the dev server manually (logs to a file) and run tests against it via E2E_BASE_URL=http://127.0.0.1:3001. db:seed:e2e seeds 3 warehouses (CBG/DEFAULT/E2E-SECOND) + admin@example.com/supplier@example.com."} -{"_type":"memory","key":"ims-staging-deploy-mechanism","value":"IMS staging (https://ims-stage.onetwo3d.co.uk, 10.0.3.99:3000) is run by the systemd service 'ims-stage-dev.service' as 'npm run dev' from /root/ims/onetwo3d-ims-isolated, with EnvironmentFile=/root/ims/onetwo3d-ims-isolated/.env.local (this is the ONLY place SETTINGS_ENCRYPTION_KEY and the correct AUTH_SECRET live; the app's plain .env lacks SETTINGS_ENCRYPTION_KEY). CORRECT deploy: (1) git pull --ff-only (it tracks origin/development) \u0026\u0026 npx prisma generate (if schema changed); (2) systemctl restart ims-stage-dev.service; (3) verify: systemctl is-active ims-stage-dev.service + curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/login == 200. Dev mode compiles from disk, so NO build step. DO NOT run scripts/deploy.sh on this box: it targets the WRONG dir (/root/ims/onetwo3d-ims) and runs 'npm start' without SETTINGS_ENCRYPTION_KEY/correct AUTH_SECRET, which grabs port 3000, blocks the systemd service's auto-restart, breaks reading encrypted settings (e.g. PO ordering throws 'SETTINGS_ENCRYPTION_KEY is required') and invalidates sessions (JWTSessionError 'no matching decryption secret' -\u003e users must re-login). The main checkout /root/ims/onetwo3d-ims is NOT the served instance. Both .env files point at the same DB onetwo3d_ims_dev@localhost."} +{"_type":"memory","key":"dolt-store-shared-between-workspaces","value":"Both /root/ims/onetwo3d-ims (main) and /root/ims/onetwo3d-ims-isolated (staging) share ONE bd Dolt database. Staging clone's .beads/embeddeddolt is a symlink to /root/ims/onetwo3d-ims/.beads/embeddeddolt. This means: (1) bd create/update/close in either workspace is immediately visible from the other — no import roundtrip. (2) JSONL export in either workspace reflects the SAME state, so the PR #157 'wrong workspace re-export' trap no longer applies for these two checkouts. (3) PR review worktrees in /tmp/pr\u003cN\u003e still have their OWN .beads/embeddeddolt (from the PR branch's content) — they are deliberately separate. Setup commit: 5a4f57e ('chore(bd): make embeddeddolt gitignore match symlinks'). To rebuild from scratch after a fresh staging checkout: rm -rf /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt \u0026\u0026 ln -s /root/ims/onetwo3d-ims/.beads/embeddeddolt /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt"} {"_type":"memory","key":"list","value":"list"} -{"_type":"memory","key":"pr-review-bd-followup-push-target","value":"When filing bd issues as PR-review follow-ups, push the resulting .beads/issues.jsonl change to the SAME PR's branch (not to whatever branch the local working tree is on). Workflow: check out the PR's branch (gh pr checkout N), file bd issues, commit only .beads/issues.jsonl, push to the PR branch. This keeps review findings tied to the PR's history and avoids creating drive-by commits on unrelated feature branches."} +{"_type":"memory","key":"local-e2e-environment-set-up-2026-06-21","value":"LOCAL E2E ENVIRONMENT (set up 2026-06-21, works): the unit-test mock can't model kit shipments, and DB-mutating prisma commands are NOT actually blocked — so run real e2e against an ISOLATED Postgres DB, never the shared live onetwo3d_ims_dev (live app is on :3000). SETUP: (1) imsdev role has CREATEDB; create DB once: PGPASSWORD=\u003cimsdev pw from .env\u003e psql -h127.0.0.1 -U imsdev -d onetwo3d_ims_dev -c 'CREATE DATABASE ims_e2e OWNER imsdev;'. (2) export DATABASE_URL=postgresql://imsdev:\u003cpw\u003e@localhost:5432/ims_e2e ; npx prisma migrate deploy (applies cleanly incl. the #333 trigger fix). (3) e2e needs SETTINGS_ENCRYPTION_KEY (only in /root/ims/onetwo3d-ims-isolated/.env.local), E2E_ROUTE_SECRET=e2e-route-secret (tests default to this), E2E_TEST_MODE=1, E2E_PORT=3001 (live app uses 3000), AUTH_SECRET (in .env). ISOLATION: inline env wins over .env (dotenv/Next don't override pre-set process.env) — verified the seed wrote to ims_e2e only. RUN: with those env vars, (chromium depends on the auth setup project automatically; reuseExistingServer reuses a manually-started \n\u003e onetwoinventory@2.0.0 dev\n\u003e next dev --port 3001\n\n\u001b[?25h). To see server-action errors, start the dev server manually (logs to a file) and run tests against it via E2E_BASE_URL=http://127.0.0.1:3001. db:seed:e2e seeds 3 warehouses (CBG/DEFAULT/E2E-SECOND) + admin@example.com/supplier@example.com."} {"_type":"memory","key":"ims-staging-dev-cache-bloat","value":"IMS staging runs 'npm run dev' (Turbopack) via systemd ims-stage-dev.service. Its .next dev cache can BLOAT/corrupt (seen at 7.3GB) after many restarts+code pulls, causing the LARGEST route (/sync = Integrations, ~190KB of client components: sync-dashboard/sync-client/xero-client/mintsoft-client) to never finish compiling — symptom: journalctl shows '○ Compiling /sync ...' with NO 'Compiled'/no GET 200, page 'won't open', dev tools show continuous 'rendering'. type-check/lint pass (it's a Turbopack dev-compile/cache issue, not a code error). FIX: systemctl stop ims-stage-dev; rm -rf /root/ims/onetwo3d-ims-isolated/.next; systemctl start ims-stage-dev (first compile of each route is then slow ~3-5s but completes). Verify: journalctl shows 'GET /sync 200'. NOTE: a symlinked node_modules breaks Turbopack build/dev ('Symlink node_modules is invalid, points out of filesystem root') — worktree gates (tsc/eslint/node tests) tolerate the symlink but 'next build'/dev do NOT, so you can't repro a dev-compile issue in a node_modules-symlinked worktree. Longer-term: consider running staging as a production build (npm run build \u0026\u0026 npm start) instead of dev mode to avoid on-demand-compile cache fragility."} -{"_type":"memory","key":"prisma-p2028-transaction-already-closed-a-rollback-cannot","value":"Prisma P2028 'Transaction already closed: A rollback cannot be executed on a committed transaction' on a StockMovement createMany is almost always a RED HERRING. The real cause is a DEFERRABLE INITIALLY DEFERRED constraint trigger (stock_movements_reporting_evidence_guard, migration 20260602103000) firing at COMMIT — createMany's batch transaction mis-surfaces the deferred RAISE EXCEPTION as P2028. To see the true error, reproduce the insert with a single db.stockMovement.create() instead of createMany: it surfaces the actual message (e.g. 'Outbound stock movement requires matching COGS evidence'). The guard requires every outbound movement (SALE_DISPATCH/PRODUCTION_OUT/outbound ADJUSTMENT) to have a cogs_entries row; it broke the forecasting historical import (zero-cost, warehouse-less SALE_DISPATCH, referenceType WcHistorical/CsvHistorical/WcInitialImport). Fixed in migration 20260616120000 by exempting exactly that shape, mirrored in lib/domain/inventory/invariants.ts requiresCogsEntryEvidence AND the SQL invariant collector (3 enforcement points must stay in sync). The app db client is a PrismaPg driver-adapter singleton over pg.Pool — after()/fire-and-forget was NOT the cause."} +{"_type":"memory","key":"pr-review-bd-followup-push-target","value":"When filing bd issues as PR-review follow-ups, push the resulting .beads/issues.jsonl change to the SAME PR's branch (not to whatever branch the local working tree is on). Workflow: check out the PR's branch (gh pr checkout N), file bd issues, commit only .beads/issues.jsonl, push to the PR branch. This keeps review findings tied to the PR's history and avoids creating drive-by commits on unrelated feature branches."} {"_type":"memory","key":"scjz-60-5-done-pr-338-2026-06","value":"scjz.60.5 DONE (PR #338, 2026-06-22): added InventorySnapshotRun per-date coverage marker (table inventory_snapshot_runs, mirrors InventoryReservationSnapshotRun) written by writeDailyInventorySnapshot (source=cron) + backfillInventorySnapshots per-day (source=backfill, skipped on dry-run); loadInventoryGlReconciliation now treats empty inventory_snapshots for the GL balance date as a genuine zero subledger (reconcile, flag stale GL) when a run marker exists, else still degrades to unavailable. Per-row valueReplayReliable gate for non-empty dates unchanged. Migration 20260622000000_inventory_snapshot_run hand-authored; all CI green incl. fresh-db-drift. Net: zero open actionable cogs-audit findings — only the scjz P1 epic container (deferred finance/staging tail) + iwrm P3 (QBO account UI, deferred-for-productization) remain."} {"_type":"memory","key":"standing-workflow-for-the-ims-cogs-audit-group","value":"STANDING WORKFLOW for the IMS COGS-audit group work (user directive 2026-06-21): pipeline PRs with CI. After pushing a PR + Codex-clean, do NOT block waiting for GitHub CI — start the next BD item immediately. When the prior PR's CI run finishes (watch in background), PAUSE the current item, return to that PR's CI results, fix any failures (CI is the real e2e/migration/invariant-preflight gate; local + Codex don't catch DB-level issues), then resume the group work. Always check 'gh pr checks \u003cN\u003e' on merged/just-pushed PRs — do not merge or move on assuming green."} -{"_type":"memory","key":"dolt-store-shared-between-workspaces","value":"Both /root/ims/onetwo3d-ims (main) and /root/ims/onetwo3d-ims-isolated (staging) share ONE bd Dolt database. Staging clone's .beads/embeddeddolt is a symlink to /root/ims/onetwo3d-ims/.beads/embeddeddolt. This means: (1) bd create/update/close in either workspace is immediately visible from the other — no import roundtrip. (2) JSONL export in either workspace reflects the SAME state, so the PR #157 'wrong workspace re-export' trap no longer applies for these two checkouts. (3) PR review worktrees in /tmp/pr\u003cN\u003e still have their OWN .beads/embeddeddolt (from the PR branch's content) — they are deliberately separate. Setup commit: 5a4f57e ('chore(bd): make embeddeddolt gitignore match symlinks'). To rebuild from scratch after a fresh staging checkout: rm -rf /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt \u0026\u0026 ln -s /root/ims/onetwo3d-ims/.beads/embeddeddolt /root/ims/onetwo3d-ims-isolated/.beads/embeddeddolt"} {"_type":"memory","key":"pr-review-bd-export-trap","value":"When filing bd follow-ups on a PR branch in a worktree, NEVER trust 'bd export' to give you a JSONL that respects the PR's pending closures — the worktree's bd uses its OWN .beads/embeddeddolt (from the branch), so bd commands in the worktree work against that workspace. The PR #157 trap (lost 49 closures) happened because I ran bd commands in the MAIN checkout, then copied its JSONL onto the worktree, overwriting the PR's closures. Safe workflows: (a) edit the PR branch's .beads/issues.jsonl DIRECTLY via Python/sed to append new issue rows — DO NOT call bd export; (b) work entirely in the worktree's bd (cd /tmp/pr\u003cN\u003e \u0026\u0026 bd create ...) so the export reflects PR state + your additions naturally; (c) the post-PR-#157 fix-up commit was fa5d214. See also dolt-store-shared-between-workspaces — that fix shares the dolt store across the main+staging checkouts but worktrees stay independent on purpose."} +{"_type":"memory","key":"prisma-p2028-transaction-already-closed-a-rollback-cannot","value":"Prisma P2028 'Transaction already closed: A rollback cannot be executed on a committed transaction' on a StockMovement createMany is almost always a RED HERRING. The real cause is a DEFERRABLE INITIALLY DEFERRED constraint trigger (stock_movements_reporting_evidence_guard, migration 20260602103000) firing at COMMIT — createMany's batch transaction mis-surfaces the deferred RAISE EXCEPTION as P2028. To see the true error, reproduce the insert with a single db.stockMovement.create() instead of createMany: it surfaces the actual message (e.g. 'Outbound stock movement requires matching COGS evidence'). The guard requires every outbound movement (SALE_DISPATCH/PRODUCTION_OUT/outbound ADJUSTMENT) to have a cogs_entries row; it broke the forecasting historical import (zero-cost, warehouse-less SALE_DISPATCH, referenceType WcHistorical/CsvHistorical/WcInitialImport). Fixed in migration 20260616120000 by exempting exactly that shape, mirrored in lib/domain/inventory/invariants.ts requiresCogsEntryEvidence AND the SQL invariant collector (3 enforcement points must stay in sync). The app db client is a PrismaPg driver-adapter singleton over pg.Pool — after()/fire-and-forget was NOT the cause."} +{"_type":"memory","key":"ims-staging-deploy-mechanism","value":"IMS staging (https://ims-stage.onetwo3d.co.uk, 10.0.3.99:3000) is run by the systemd service 'ims-stage-dev.service' as 'npm run dev' from /root/ims/onetwo3d-ims-isolated, with EnvironmentFile=/root/ims/onetwo3d-ims-isolated/.env.local (this is the ONLY place SETTINGS_ENCRYPTION_KEY and the correct AUTH_SECRET live; the app's plain .env lacks SETTINGS_ENCRYPTION_KEY). CORRECT deploy: (1) git pull --ff-only (it tracks origin/development) \u0026\u0026 npx prisma generate (if schema changed); (2) systemctl restart ims-stage-dev.service; (3) verify: systemctl is-active ims-stage-dev.service + curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/login == 200. Dev mode compiles from disk, so NO build step. DO NOT run scripts/deploy.sh on this box: it targets the WRONG dir (/root/ims/onetwo3d-ims) and runs 'npm start' without SETTINGS_ENCRYPTION_KEY/correct AUTH_SECRET, which grabs port 3000, blocks the systemd service's auto-restart, breaks reading encrypted settings (e.g. PO ordering throws 'SETTINGS_ENCRYPTION_KEY is required') and invalidates sessions (JWTSessionError 'no matching decryption secret' -\u003e users must re-login). The main checkout /root/ims/onetwo3d-ims is NOT the served instance. Both .env files point at the same DB onetwo3d_ims_dev@localhost."}