Skip to content

feat(qbo): cross-port payment-reversal detection + chargeback wiring (1ljl)#351

Merged
OneTwo3D merged 3 commits into
developmentfrom
fix/qbo-chargeback-parity-1ljl
Jun 23, 2026
Merged

feat(qbo): cross-port payment-reversal detection + chargeback wiring (1ljl)#351
OneTwo3D merged 3 commits into
developmentfrom
fix/qbo-chargeback-parity-1ljl

Conversation

@OneTwo3D

Copy link
Copy Markdown
Owner

Summary

Cross-ports the Xero payment-reversal detection (audit-M-acct #3, scjz.70/.71, #343) to the QuickBooks connector. Before this, 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.

What changed (lib/connectors/quickbooks/payment-poller.ts)

  • classifyQboReversals + fetchReversedEntityIds — QBO equivalent of Xero's fetchReversedInvoiceIds, returning the { all, voided } contract:
    • Balance > 0 ⇒ payment un-applied, invoice still live → chargeback-eligible
    • TotalAmt = 0 ⇒ voided; QBO has already reversed AR/revenue → skip chargeback (a credit note would double-reverse)
  • Sales-reversal loop wires raiseChargebackForReversedOrder(orderId, { internalBypassToken }) on revenueDeferredDate && !invoiceVoided. paidAt is cleared only after the chargeback is recorded (retry-safe); a failed chargeback leaves paidAt set.
  • Bill-reversal loop clears paidAt with a WARNING (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's errors.length === 0. A failed chargeback pushed to errors and continued (intending a retry) but left allQueriesSucceeded true — so the watermark advanced past the window, the reversed invoice fell out of the next LastUpdatedTime > since query, and the chargeback never actually retried. Fixed by clearing allQueriesSucceeded on chargebackFailed, restoring Xero's hold-and-replay guarantee.

Tests / gates

  • 5 new unit tests for the pure classifier (tests/accounting/qbo-payment-reversal.test.ts)
  • type-check, eslint, check:connector-fetch-boundaries, and the full 58-test accounting suite all green

Remaining

  • ⚠️ Live QBO sandbox validation is still pending — the Xero Demo company can't exercise the QBO path. Tracked on onetwo3d-ims-1ljl.

Part of epic onetwo3d-ims-4wuu. Closes onetwo3d-ims-1ljl (code complete).

🤖 Generated with Claude Code

OneTwo3D IMS and others added 2 commits June 23, 2026 13:54
…(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>

@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: 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".

Comment thread app/actions/sales.ts
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' }

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 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@OneTwo3D OneTwo3D changed the base branch from main to development June 23, 2026 15:13
…back-parity-1ljl

# Conflicts:
#	.beads/issues.jsonl
@OneTwo3D OneTwo3D merged commit ec7bf6e into development Jun 23, 2026
10 checks passed
@OneTwo3D OneTwo3D deleted the fix/qbo-chargeback-parity-1ljl branch June 23, 2026 16:24
OneTwo3D pushed a commit that referenced this pull request Jun 23, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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