Skip to content

feat(quickbooks): disambiguate payment-id confirmation and surface unposted DDB receipts - #239

Merged
spizeck merged 2 commits into
mainfrom
feat/qbo-confirm-id-receipt-visibility
Oct 15, 2026
Merged

spizeck merged 2 commits into
mainfrom
feat/qbo-confirm-id-receipt-visibility

Conversation

@spizeck

@spizeck spizeck commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two findings from the October 15 production reconciliation, each fixed narrowly:

  • Confirmation UX — the move_payment_to_clearing approval prompt interpolated the human-readable PaymentRefNum (e.g. 1626166) as the value to enter, while the field validates against the internal QBO Payment ID (e.g. 1813). The prompt now labels both identifiers explicitly (QBO Payment ID vs Payment reference) and reads "Enter QBO Payment ID 42 to confirm this payment." Backend validation is untouched — confirmQboPaymentId still must equal the proposal's bound qboRefs.paymentId; the reference can never substitute.
  • Unposted DDB receipts are now visible — the October 4 $5.00 pre-flight charge (predating the Sales-Receipt sync feature, outside the sweep lookback) had no QBO Sales Receipt yet the payout ledger read as reconciled. The ledger now fetches each resolved DDB payment's durable sync record: anything other than synced — including no record — marks the line ddb_receipt_unverified and raises a ddb_receipt_unposted exception naming the payment id and expected document. The executor's existing ddb_gross_unposted refusal stays the apply-time gate; this adds the observability layer.
  • Adopt-path hardening — the marker-adopt recovery scan now verifies the found receipt's DepositToAccountRef against the expected mapped account. A marker-carrying receipt deposited to the wrong account parks needs_attention (wrong_deposit_account) for a human instead of being counted as synced — no blind replacement is ever created.

Root cause (October 4 $5.00)

Confirmed mechanism, not a webhook failure: the charge predates the Sales-Receipt sync feature, so no durable qboSyncRecords record was ever created; the sweep's bounded lookback (72 h default, 30-day cap) deliberately does not backfill it — see Backfill bounds in docs/operations/quickbooks.md. The ledger then compounded it: a ddb lane proved which charge it was but nothing proved the gross sale had posted. Detection now flows through the new exception; recovery was correctly done manually (the marker-adopt path would have adopted a receipt carrying ddb:<paymentId>).

What did not change

Test plan

  • npx tsc --noEmit — clean
  • npm run lint — pass (pre-existing jsx-ast-utils warning)
  • npm test — 1252 pass (+9: ddb posted/unposted/pending/error receipt statuses, sync-record fetch wiring, wrong-account adoption, canonicalizer shape)
  • npm run test:rules — 49 pass
  • npm run build — clean
  • npx playwright test smoke-tests/admin-quickbooks.spec.ts — 18/18 incl. new test: labels distinguish ID vs reference, ref value cannot confirm, internal ID unlocks Approve
  • npm run check:md-links / check:react-versions — pass
  • Ops doc updated: receipt detection/recovery, adopt account check, confirmation wording, why a balanced payout ≠ every sale posted

Generated with Devin

Summary by Sourcery

Harden QuickBooks payout reconciliation by distinguishing payment identifiers, surfacing unverified DDB receipts, and validating adopted receipts against the expected deposit account.

New Features:

  • Surface unverified or unposted DDB Sales Receipts in payout reconciliation through durable sync-status checks and explicit exceptions.

Bug Fixes:

  • Clarify payment confirmation so reviewers must enter the internal QBO Payment ID rather than the human-readable payment reference.
  • Prevent marker-based receipt adoption when the receipt is in the wrong or unreadable deposit account.

Enhancements:

  • Fail closed on receipt adoption account mismatches while preserving manual recovery paths.
  • Document receipt detection, recovery, account validation, and the distinction between balanced payouts and posted sales.

Documentation:

  • Update QuickBooks operations guidance for unposted receipts, adoption account checks, and payment confirmation identifiers.

Tests:

  • Add coverage for payment-ID confirmation UX, missing and incomplete receipt sync records, and safe receipt adoption behavior.

Summary by CodeRabbit

  • Bug Fixes
    • QuickBooks receipt recovery now flags receipts deposited to an unexpected or unreadable account for review instead of treating them as synced or creating a replacement.
    • Stripe payout reconciliation now flags DDB payments whose QuickBooks receipts are missing or not fully synced.
    • Payout approval distinguishes the QBO Payment ID required for confirmation from the separate payment reference.
  • Documentation
    • Updated the operations guide with receipt verification, payout reconciliation, and options for handling payments outside the reconciliation window.

…posted DDB receipts

Two production findings from the October 15 reconciliation:

- The approval prompt interpolated the PaymentRefNum as the value to
  enter, but the field validates against the internal QBO Payment ID.
  The UI now labels both identifiers explicitly ("QBO Payment ID" /
  "Payment reference") and the instruction names the required value.
  Backend validation is unchanged — PaymentRefNum can never substitute.

- A $5 pre-flight website charge predating the sync feature had no QBO
  Sales Receipt yet the payout ledger read as fully reconciled. DDB
  lines now load their durable sync record: anything other than
  `synced` (or no record) marks the line ddb_receipt_unverified and
  raises a ddb_receipt_unposted exception naming the payment and the
  expected SalesReceipt. The executor's existing ddb_gross_unposted
  refusal remains the apply-time gate.

- Marker-adopt now verifies the found receipt's DepositToAccountRef
  against the expected mapped account — a receipt parked in the wrong
  account lands needs_attention (wrong_deposit_account) instead of
  being counted as synced.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
deepdivebrewing-web Ready Ready Preview Oct 15, 2026 1:17pm UTC

Request Review

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR makes QBO payment approvals unambiguous, surfaces missing or incomplete DDB Sales Receipt posting in payout reconciliation through durable sync-record checks, and fails closed when marker-based receipt recovery finds a receipt in the wrong deposit account, without adding new QBO or Stripe writes.

Sequence diagram for DDB receipt verification during payout reconciliation

sequenceDiagram
    participant P as Stripe payout processor
    participant F as Firebase sync records
    participant L as Payout ledger
    participant A as Admin
    P->>F: loadDdbReceiptStatuses()
    F-->>P: status per resolved payment
    P->>L: buildPayoutLedger(ddbReceiptStatus)
    alt status is synced
        L-->>A: DDB line verified
    else missing, pending, failed, or needs_attention
        L-->>A: ddb_receipt_unposted exception
    end
Loading

Sequence diagram for marker-adopt deposit account validation

sequenceDiagram
    participant W as QBO sync worker
    participant Q as QuickBooks
    participant H as Human operator
    W->>Q: findQboSalesReceiptForMarker()
    Q-->>W: marker receipt with deposit account
    alt expected deposit account
        W-->>H: synced existing receipt
    else wrong deposit account
        W-->>H: needs_attention wrong_deposit_account
        Note over W,Q: No replacement receipt is created
    end
Loading

File-Level Changes

Change Details Files
Clarified payment confirmation UX so reviewers enter the immutable internal QBO Payment ID rather than the human-readable payment reference.
  • Displayed both identifiers with explicit labels.
  • Updated confirmation instructions, placeholder, and accessibility label.
  • Added browser coverage proving the reference is rejected and the internal ID enables approval.
components/admin-quickbooks-payouts.tsx
smoke-tests/admin-quickbooks.spec.ts
docs/operations/quickbooks.md
Added read-only ledger observability for DDB payments whose Sales Receipt posting is not durably confirmed.
  • Loaded sync-record statuses for resolved DDB payments using deterministic IDs and chunked Firestore queries.
  • Marked non-synced or missing records as unverified and emitted a detailed ddb_receipt_unposted exception.
  • Preserved the executor's existing ddb_gross_unposted apply-time refusal and legacy behavior when status data is not supplied.
  • Covered missing, pending, failed, needs-attention, syncing, and synced states.
lib/stripe-payouts.ts
lib/stripe-payouts-common.ts
tests/lib/stripe-payouts-common.test.ts
tests/lib/stripe-payouts.test.ts
docs/operations/quickbooks.md
Hardened marker-based Sales Receipt adoption by validating the receipt's deposit account before treating it as synchronized.
  • Included DepositToAccountRef in correlation queries and canonicalized receipt results.
  • Parked marker receipts in the wrong account as needs_attention with wrong_deposit_account.
  • Prevented duplicate replacement creation and verified both correct-account adoption and wrong-account failure paths.
lib/qbo-protocol.ts
lib/qbo-api.ts
lib/qbo-sync.ts
tests/lib/qbo-salesreceipt.test.ts
tests/lib/qbo-sync-worker.test.ts
docs/operations/quickbooks.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-15T13:12:17.440388Z aac2d94 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 0412fe4c-9d57-4b90-8126-73ffcea2f63d
📥 Commits

Reviewing files that changed from the base of the PR and between aac2d94 and 0a89ed1.

📒 Files selected for processing: 5
  • docs/operations/quickbooks.md
  • lib/qbo-sync.ts
  • lib/stripe-payouts.ts
  • tests/lib/qbo-sync-worker.test.ts
  • tests/lib/stripe-payouts.test.ts

Included review availability: This review used your included allowance. 1 included review remains after this review. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.

📝 Walkthrough

QuickBooks receipt recovery now checks the matched receipt’s deposit account. Stripe payout reconciliation checks DDB receipt sync status before treating receipts as verified. The proposal confirmation UI distinguishes the QBO Payment ID from the payment reference.

Changes: QuickBooks payout operations

Layer / File(s) Summary
Verify deposit accounts on recovered receipts
lib/qbo-protocol.ts, lib/qbo-api.ts, lib/qbo-sync.ts, tests/lib/qbo-salesreceipt.test.ts, tests/lib/qbo-sync-worker.test.ts, docs/operations/quickbooks.md
Recovery queries return the matched receipt’s deposit account. If the account is unreadable or differs from the mapped account, sync returns needs_attention with wrong_deposit_account instead of adopting the receipt. Tests cover matching, missing, and mismatched accounts.
Check receipt status in payout reconciliation
lib/stripe-payouts-common.ts, lib/stripe-payouts.ts, tests/lib/stripe-payouts-common.test.ts, tests/lib/stripe-payouts.test.ts, docs/operations/quickbooks.md
The payout processor loads QBO sync statuses for DDB payment IDs and passes them to the ledger. A missing or non-synced status adds ddb_receipt_unverified evidence and a ddb_receipt_unposted exception. Tests cover absent, unfinished, synced, and omitted status data.
Identify the payment ID required for confirmation
components/admin-quickbooks-payouts.tsx, smoke-tests/admin-quickbooks.spec.ts, docs/operations/quickbooks.md
The proposal UI displays the QBO Payment ID and payment reference separately. It requires the QBO Payment ID for confirmation. The smoke test checks that entering only the reference does not enable approval.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant processStripePayoutLedger
  participant loadDdbReceiptStatuses
  participant Firestore
  participant buildPayoutLedger
  processStripePayoutLedger->>loadDdbReceiptStatuses: DDB payment IDs
  loadDdbReceiptStatuses->>Firestore: Query sync records in chunks of 30
  Firestore-->>loadDdbReceiptStatuses: Available sync statuses
  loadDdbReceiptStatuses-->>processStripePayoutLedger: Statuses keyed by payment ID
  processStripePayoutLedger->>buildPayoutLedger: DDB identity records and receipt statuses
  buildPayoutLedger-->>processStripePayoutLedger: Ledger exceptions for unposted receipts
Loading
Merge Risk: ⚪ Minimal · up to 0a89e

Eligible payouts remain protected from fee and transfer execution when a DDB receipt is unposted. No merge-blocking issue was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 11 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely summarizes the primary changes: payment-ID confirmation and unposted DDB receipt detection.
Description check Passed The description provides detailed change context, root cause, scope, test results, deployment impact, and documentation updates. It does not reproduce the template headings or include a Closes issue r…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 11 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai 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.

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="lib/qbo-sync.ts" line_range="572-574" />
<code_context>
+      // the gross is NOT in the expected account: fail closed for a
+      // human rather than marking the sync complete or posting a
+      // duplicate receipt.
+      if (
+        existing.depositToAccountId &&
+        existing.depositToAccountId !== depositAccountId
+      ) {
+        return attention(
</code_context>
<issue_to_address>
**Unverified receipts appear posted**

When the marker lookup returns a receipt without a usable `DepositToAccountRef`, `runSyncWrite` skips the account mismatch check and marks the adopted receipt `synced`, so the sync record reports it posted to the mapped account without proving its destination.

Treat a missing or unparseable deposit account as unverified and require human review instead of returning `synced`.
</issue_to_address>

### Comment 2
<location path="lib/stripe-payouts-common.ts" line_range="966" />
<code_context>
+        // than letting the line read as complete.
+        if (input.ddbReceiptStatus) {
+          const receipt = input.ddbReceiptStatus.get(ddbHit);
+          if (receipt !== "synced") {
+            evidence.push("ddb_receipt_unverified");
+            exceptions.push({
</code_context>
<issue_to_address>
**Moved receipts remain verified**

When a synced receipt is moved out of the mapped account, deleted, or the expected account mapping changes, `buildPayoutLedger` trusts the durable `synced` status, while `verifyDdbGrossReceipts` checks only status and entity ID; `processQboSyncRecord` also returns `already_synced` without rechecking QuickBooks, so the receipt still appears valid and aggregate transfers can execute.

Recheck the receipt's current existence and deposit account in QuickBooks before treating it as verified in the ledger or allowing aggregate transfers.
</issue_to_address>

### Comment 3
<location path="lib/qbo-protocol.ts" line_range="739" />
<code_context>
     );
   }
-  return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' and TxnDate = '${txnDate}' orderby TxnDate desc startposition ${startPosition} maxresults ${maxResults}`;
+  return `select Id, PrivateNote, TxnDate, DepositToAccountRef from SalesReceipt where CustomerRef = '${safe}' and TxnDate = '${txnDate}' orderby TxnDate desc startposition ${startPosition} maxresults ${maxResults}`;
 }

</code_context>
<issue_to_address>
**Marked receipts can be duplicated**

When a manually created, marker-carrying receipt has a different customer or transaction date from the sync record's configured customer and payment `paidAt` date, `qboSalesReceiptCorrelationQuery` searches only the configured customer and exact transaction date, so `findQboSalesReceiptForMarker` misses a marked receipt outside either filter and `runSyncWrite` proceeds to create another receipt. This contradicts the recovery guidance that any receipt carrying the marker will be adopted.

Either make marker recovery find the payment marker independently of customer and date, or document and enforce those required receipt fields before claiming it will be adopted.

Also at `docs/operations/quickbooks.md:459-460`.
</issue_to_address>

### Comment 4
<location path="lib/stripe-payouts.ts" line_range="318-321" />
<code_context>
+      .get();
+    for (const doc of snap.docs) {
+      const paymentId = idToPayment.get(doc.id);
+      const status = doc.data().status;
+      if (paymentId && typeof status === "string") {
+        statuses.set(paymentId, status);
+      }
+    }
+  }
</code_context>
<issue_to_address>
**Receipts without IDs appear verified**

When a sync record has status `synced` but its `qboEntityId` is missing or empty, `loadDdbReceiptStatuses` records the status as `synced` without checking the entity ID, so `buildPayoutLedger` suppresses the unposted exception and reviewers see an unverified gross as confirmed.

Only report a receipt as synced in the status map when its sync record also has a nonempty `qboEntityId`.

Also at `lib/stripe-payouts-common.ts:966`.
</issue_to_address>

### Comment 5
<location path="lib/stripe-payouts-common.ts" line_range="968-973" />
<code_context>
+          const receipt = input.ddbReceiptStatus.get(ddbHit);
+          if (receipt !== "synced") {
+            evidence.push("ddb_receipt_unverified");
+            exceptions.push({
+              code: "ddb_receipt_unposted",
+              ref: txn.txnId,
+              detail: `DDB payment ${ddbHit} has no confirmed QBO Sales Receipt (sync status: ${receipt ?? "no record"}) — the gross sale is not proven posted to Stripe clearing. Expected document: SalesReceipt. Check the QBO sync panel for the durable sync record.`,
+              amountMinor: txn.grossMinor,
+            });
+          }
+        }
</code_context>
<issue_to_address>
**Receipt warnings are dropped**

When a payout has more than 100 exceptions and a DDB receipt warning falls after the first 100, `processStripePayoutLedger` persists only the first 100 exceptions, dropping later `ddb_receipt_unposted` warnings so the payout view omits affected receipts.

Ensure DDB receipt warnings are retained when persisting the ledger, rather than dropping them at the 100-exception limit.
</issue_to_address>

### Comment 6
<location path="lib/stripe-payouts-common.ts" line_range="964-966" />
<code_context>
+        // that balances arithmetically while the receipt is missing or
+        // unfinished is NOT fully reconciled; surface it loudly rather
+        // than letting the line read as complete.
+        if (input.ddbReceiptStatus) {
+          const receipt = input.ddbReceiptStatus.get(ddbHit);
+          if (receipt !== "synced") {
+            evidence.push("ddb_receipt_unverified");
+            exceptions.push({
</code_context>
<issue_to_address>
**Resolved warnings remain visible**

When a payout ledger is built while its DDB sync record is unfinished and that record later reaches `synced`, the ledger keeps the earlier `ddb_receipt_unposted` exception, and later sweeps skip the unchanged payout once its ledger is up to date; operators continue seeing a stale exception until they manually reprocess the payout.

Invalidate or rebuild the payout ledger when the associated DDB sync record changes to `synced`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 6 findings to address first, and a marker receipt found in the wrong deposit account now leaves its sync record needing human attention, and a DDB payout line without a confirmed receipt is recorded as an exception. If either check is wrong, it can delay reconciliation, but the affected records and payout ledger can be repaired or recomputed; reverting alone may not clear the stored state.

Blocking findings: lib/qbo-sync.ts:574, lib/stripe-payouts-common.ts:966, lib/qbo-protocol.ts:739, lib/stripe-payouts.ts:321, lib/stripe-payouts-common.ts:973, and 1 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread lib/qbo-sync.ts Outdated
Comment thread lib/stripe-payouts-common.ts
Comment thread lib/qbo-protocol.ts
Comment thread lib/stripe-payouts.ts Outdated
Comment thread lib/stripe-payouts-common.ts
Comment thread lib/stripe-payouts-common.ts
- Marker adoption: a receipt whose DepositToAccountRef cannot be read
  is unproven, not verified — now fails closed to needs_attention
  instead of silently syncing.
- Ledger receipt status: a sync record marked `synced` with no
  qboEntityId is unfinished evidence — the line now raises
  ddb_receipt_unposted with `synced_no_entity` rather than reading as
  confirmed.
- Docs: correct the marker-adopt wording — the correlation lookup is
  bounded to the mapped generic customer and the payment's posted date.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@spizeck
spizeck merged commit 444eff8 into main Oct 15, 2026
5 checks passed
@spizeck
spizeck deleted the feat/qbo-confirm-id-receipt-visibility branch October 15, 2026 14:36

This branch was successfully deployed

1 active deployment
Preview — 0a89ed18 Deployed Oct 15, 2026 by vercel[bot]
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