fix(deals): complete deal/archive offer only when ALL milestones settled (#108) - #110
Open
adamkrawczyk wants to merge 1 commit into
Open
fix(deals): complete deal/archive offer only when ALL milestones settled (#108)#110adamkrawczyk wants to merge 1 commit into
adamkrawczyk wants to merge 1 commit into
Conversation
…when ALL milestones settled (#108) releaseMilestonePayment() used to flip deals.status='completed' and archive the backing offer on the release of ANY single milestone, even with siblings still outstanding (pending/in_progress/delivered/disputed). This misrepresented partially-settled deals as fully completed to both parties and to fee-revenue reporting, and took the seller's offer off the market mid-deal. Guard both completion paths (funded CAS transaction + zero-payment early return) with the coverage-not-existence idiom already used by completeDealMilestones()'s unbacked-milestone check: NOT EXISTS sibling milestone with status NOT IN ('accepted','cancelled'). Also serialize same-deal releases with a deal-row FOR UPDATE lock: under READ COMMITTED, two parallel sibling releases would each observe the other still outstanding, both skip completion, and leave a fully-settled deal stuck at 'delivered'. Single-milestone deals: NOT EXISTS trivially true — legacy behavior preserved (covered by regression test). Approved direction per decision on #108. 5-step stage: FIX (delete/simplify rejected — the completion transition is load-bearing consumed behavior). Breaker: RED proof 4/7 failed pre-fix; post-fix 9/9 green incl. 2 concurrency attacks (parallel sibling releases; cross-deal no-deadlock). Full suite: build 0 errors, 370/370 tests, lint-routes clean.
Owner
Author
|
Merge-order coordination with #107 (per the decision on #108): both PRs edit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
releaseMilestonePayment()completed the entire deal and archived the backing offer on the release of any single milestone — even with sibling milestones still outstanding (pending / in_progress / delivered-but-unreleased / disputed). Multi-milestone deals were misrepresented as fully settled to both parties, to dashboards, and to fee-revenue reporting, while 2/3 of the value was still in flight — and the seller's offer was pulled off the market mid-deal.Implements the approved Option 1 from the decision on #108: guard the
deals.status='completed'+offers.status='archived'writes with the coverage-not-existence idiom already proven in this same file (completeDealMilestones()'s unbacked-milestone check, deal-helpers.ts ~486).Change
apps/api/src/shared/deal-helpers.ts—releaseMilestonePayment(), both completion paths:AND NOT EXISTS (SELECT 1 FROM milestones sibling WHERE sibling.deal_id = $1 AND sibling.id != $2 AND sibling.status NOT IN ('accepted','cancelled')). Usespayment.deal_idfrom the existing join — no extra reads.FOR UPDATElock at transaction start (both paths). Breaker attack fix(WIS-249): make browse audit writes nonblocking #2 found that under READ COMMITTED, two parallel releases of sibling milestones each observe the other still outstanding → both skip completion → fully-settled deal stuck at 'delivered' forever. The lock makes sibling-coverage check + completion atomic per deal. Single lock row per transaction, no lock-ordering cycle (cross-deal parallel releases covered by test).Single-milestone deals (today's common case): the NOT EXISTS is trivially true — behavior preserved byte-for-byte, covered by an explicit legacy regression test.
Impact on callers
All 4 call sites (payments.ts buyer-accept, disputes.ts ×2, completeDealMilestones tail) get the corrected semantics from the single shared implementation — no per-caller changes needed.
completeDealMilestones()'s per-milestone loop now converges correctly: each release marks its milestone accepted; the LAST one flips the deal.audit_log'payment.release' rows, notifications, fee math, settlement-integrity gate, and the buyer-signed release flow are untouched.Breaker report
RED proof (pre-fix, same branch minus the fix): 4/7 tests failed —
Post-fix: 9/9 green, including 2 concurrency attacks.
Attacks run (≥3):
migrations/001_init.sql:88) — no NULL trap in NOT IN.Promise.allrelease of 2 sibling milestones — pre-lock this leaves the deal stuck at 'delivered' with all milestones accepted (both txns skip completion). Fixed by the FOR UPDATE deal-row lock; test asserts completed+archived. Cross-deal parallel releases: no deadlock (different rows), test asserts both settle.Verification (H5 full suite)
npm run build— 0 errors (all workspaces)npm run test— 370/370 pass (50 files; 9 new tests)bash scripts/lint-routes.sh— cleanNotes
Closes #108