feat(qbo): cross-port payment-reversal detection + chargeback wiring (1ljl)#351
Conversation
…(1ljl) 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) <noreply@anthropic.com>
…351 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a7243f80d7
ℹ️ 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".
| description: `Payment reversed on order ${order.orderNumber ?? order.externalOrderNumber ?? orderId} that already has prior refunds — auto-chargeback skipped (remaining balance is ambiguous); raise the credit note manually.`, | ||
| resolveUser: false, | ||
| }) | ||
| return { raised: false, reason: 'order has prior refunds — manual chargeback required' } |
There was a problem hiding this comment.
Keep paidAt set when chargeback needs manual handling
When a deferred order with prior refunds hits a payment reversal, both payment pollers only mark the chargeback as failed when chargeback.error is set, so returning a reason here is treated as success and they clear paidAt. Because this path creates no credit note, the order drops out of future paid-order reversal scans while recognized revenue remains unreversed; return an error (or have the pollers treat manual-required reasons as failures) for this and the analogous discount/manual-handling path below.
Useful? React with 👍 / 👎.
| // — the numerator only covers observed days — which then inflated any turnover | ||
| // ratio built on this denominator. Consistent with the turnover report, which | ||
| // also divides by observed snapshot days. | ||
| return roundValue(total.div(totalByDate.size)).toFixed(6) |
There was a problem hiding this comment.
Count covered zero-stock days in averages
After this change, a successful inventorySnapshotRun with no inventorySnapshot rows represents a covered zero-stock day, but dividing by totalByDate.size ignores those zero days. For example, a two-day window with £100 on day one and a covered zero-stock day two reports an average of £100 instead of £50, distorting turnover and inventory reports; use the run markers to include covered zero days while still excluding genuinely missing days.
Useful? React with 👍 / 👎.
…back-parity-1ljl # Conflicts: # .beads/issues.jsonl
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Cross-ports the Xero payment-reversal detection (audit-M-acct #3, scjz.70/.71, #343) to the QuickBooks connector. Before this,
pollQuickBooksPaymentswas 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.What changed (
lib/connectors/quickbooks/payment-poller.ts)classifyQboReversals+fetchReversedEntityIds— QBO equivalent of Xero'sfetchReversedInvoiceIds, returning the{ all, voided }contract:Balance > 0⇒ payment un-applied, invoice still live → chargeback-eligibleTotalAmt = 0⇒ voided; QBO has already reversed AR/revenue → skip chargeback (a credit note would double-reverse)raiseChargebackForReversedOrder(orderId, { internalBypassToken })onrevenueDeferredDate && !invoiceVoided.paidAtis cleared only after the chargeback is recorded (retry-safe); a failed chargeback leavespaidAtset.paidAtwith aWARNING(no chargeback on the purchase side).Adversarial finding (fixed in this PR)
A QBO-specific divergence from Xero: the QBO cursor gate is
allQueriesSucceeded, not Xero'serrors.length === 0. A failed chargeback pushed toerrorsandcontinued (intending a retry) but leftallQueriesSucceededtrue — so the watermark advanced past the window, the reversed invoice fell out of the nextLastUpdatedTime > sincequery, and the chargeback never actually retried. Fixed by clearingallQueriesSucceededonchargebackFailed, restoring Xero's hold-and-replay guarantee.Tests / gates
tests/accounting/qbo-payment-reversal.test.ts)type-check,eslint,check:connector-fetch-boundaries, and the full 58-test accounting suite all greenRemaining
onetwo3d-ims-1ljl.Part of epic
onetwo3d-ims-4wuu. Closesonetwo3d-ims-1ljl(code complete).🤖 Generated with Claude Code