Skip to content

fix(api/purchases): guard web purchase execution (closes #643, #644, #647) - #655

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/643-purchase-guards
May 22, 2026
Merged

cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/643-purchase-guards

Conversation

@cristim

@cristim cristim commented May 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Three guards on the web executePurchase path (#643, #644, #647).

Test plan

Closes #643, #644, #647.

Note: TestPGXMock_GetExecutionByID_WithTimestamps fails on the unmodified base (already tracked as #624/#627) — unrelated to this PR.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added duplicate purchase submission detection to prevent accidental resubmissions within a 2-minute window.
  • Bug Fixes

    • Execute button is now disabled during confirmation dialog and network request to prevent double-click submissions.
    • Enhanced purchase request validation to ensure consistency between original and scaled recommendation counts.
  • Tests

    • Expanded test coverage for purchase submission guards and idempotency checks.

Review Change Stack

cristim added 2 commits May 21, 2026 14:53
…cs and double-submit

Two related guards on the web executePurchase path:

- #643: validate each client-supplied recommendation's Provider/Service/
  Term/Payment/Count at the API boundary (validatePurchaseRecommendation in
  validation.go), wired into validateExecutePurchaseRequest after the account
  scope check. Per-provider Term (1/3) and Payment whitelists reject malformed
  values (Term:7, Payment:"foo", non-positive Count, empty/all provider) before
  they reach the cloud SDK with a silent default substituted. Scoped to the web
  execute path only; the retry path replays already-validated recs and is not
  re-gated.

- #644: submit-time idempotency. purchaseIdempotencyKey hashes actor + sorted
  rec tuples + capacity; findDuplicatePendingExecution recomputes the key for
  recent web-sourced pending executions (no schema change) and collapses a
  double-click/retry within a 2-minute window onto the existing execution
  instead of minting a second approvable row (double-spend). The frontend
  execute button is now disabled before the confirm dialog and network call in
  both the single and fan-out paths, and re-enabled on cancel.

Updated existing executePurchase / per-account-perms tests to carry valid recs
and the new GetPendingExecutions lookup; added table-driven validation tests,
idempotency-key and duplicate-lookup tests, and frontend button-disable tests.

#647 (server-side capacity_percent consistency) requires plumbing the
pre-scaling count through the frontend and rec type; split into a follow-up to
keep this change focused.
…ts (#647)

capacity_percent was a decorative audit-only field: the frontend scaled
rec counts client-side, sent the percent alongside, and the backend stored
it without ever cross-checking that the scaled counts agreed with the
recorded percent. The audit trail for a financial action could claim e.g.
"50% capacity" while the recs summed to 100%.

Fix: stamp the pre-scaling recommended count onto each rec at submit time
(RecommendedCount in config.RecommendationRecord, recommended_count on the
TS Recommendation/LocalRecommendation wire types) and add
validateCapacityConsistency, wired into validateExecutePurchaseRequest after
the per-rec content validation. It rejects any rec where
floor(RecommendedCount*pct/100) != Count with a 400. The check is opt-in
per rec: recs that carry no recommended_count (legacy callers, single-rec
full-capacity purchases, retry replays) are skipped, so the change is
backward-compatible and never produces a false rejection for callers that
predate the field. JSONB persistence means no schema change.

The frontend stamps recommended_count = r.count on the scaled copy in
handleBulkPurchaseClick before the counts are floored, so the value the
backend verifies always describes the recs it accompanies.

Tests: table-driven validateCapacityConsistency cases (full/partial/floor/
mismatch/absent/mixed); a frontend round-trip test that recommended_count
survives into the executePurchase POST body on the single-rec path.
@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/bug Defect labels May 21, 2026
@cristim

cristim commented May 21, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 21, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@cristim has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 26 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5fa7b62f-196f-4ed7-8c23-e7d290903280

📥 Commits

Reviewing files that changed from the base of the PR and between bd77fef and d249096.

📒 Files selected for processing (11)
  • internal/analytics/collector_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_guards_test.go
  • internal/api/mocks_test.go
  • internal/api/validation.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/mocks/stores.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go
📝 Walkthrough

Walkthrough

This PR addresses duplicate purchase submissions and capacity consistency validation across frontend and backend. Frontend disables the execute button while awaiting confirmation and restores it on cancel. Backend validates per-recommendation fields, detects duplicate submissions via stable idempotency keys within a 2-minute window, and verifies capacity-scaling math using preserved pre-scaled counts.

Changes

Purchase Execution Guards and Validations

Layer / File(s) Summary
Data type contracts for recommended_count tracking
frontend/src/api/types.ts, frontend/src/types.ts, internal/config/types.go
Recommendation, LocalRecommendation, and RecommendationRecord types gain optional recommended_count fields to carry pre-scaling counts through the request for backend validation.
Frontend double-submit prevention via button state
frontend/src/app.ts, frontend/src/__tests__/purchase-execution-toast.test.ts
Execute button is disabled before confirmation dialog with "Sending…" label and re-enabled with original label on cancel. Tests verify the guard prevents POST until dialog resolves and restores button on user cancel.
Frontend preserves pre-scaled count in capacity scaling
frontend/src/recommendations.ts, frontend/src/__tests__/purchase-execution-toast.test.ts
Scaled recommendation objects now preserve original count in recommended_count field for backend capacity-percent cross-checks. Test asserts both scaled count and original recommended_count appear in POST payload.
Backend per-recommendation field validation at API boundary
internal/api/validation.go, internal/api/handler_purchases.go
Request validator enforces Term/Payment/Provider/Service/Count presence and validity against provider whitelists, normalizes capacity-percent (default 100, bounds [1,100]), and cross-checks scaled counts against preserved RecommendedCount.
Backend submit-time idempotency to prevent duplicate executions
internal/api/handler_purchases.go
Handler computes stable SHA-256 idempotency key from creator ID, capacity percent, and normalized/sorted recommendations; scans pending web-sourced executions within 2-minute window and returns early duplicate response (duplicate=true) when matching key found.
Comprehensive test suite for purchase guards and validations
internal/api/handler_purchases_guards_test.go
New test file with 8 tests covering per-recommendation validation, zero-count retry acceptance, idempotency key stability and discrimination, duplicate detection with creator/source/time-window matching, duplicate response formatting, and capacity-percent consistency including floor behavior.
Update existing test cases for new validation and idempotency requirements
internal/api/handler_per_account_perms_test.go, internal/api/handler_purchases_test.go
Handler tests stub GetPendingExecutions to avoid false idempotency positives and expand request recommendation JSON to include all required fields (provider, service, count, term, payment) alongside pricing.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • LeanerCloud/CUDly#294: Modifies the same purchase execution UI flow in frontend/src/app.ts around button label messaging and dialog handling.
  • LeanerCloud/CUDly#309: Updates the same internal/api/handler_per_account_perms_test.go test to adjust request/stubbing for purchase execution with per-recommendation field validation.
  • LeanerCloud/CUDly#600: Extends the frontend purchase-execution flow in handleExecutePurchase/handleFanOutExecute to preserve additional per-recommendation fields in POST body payloads.

Suggested Labels

priority/p1, severity/high, urgency/this-sprint, impact/all-users

Poem

🐰 A button that waits for the dialog to resolve,
A count that remembers its pre-scaled soul,
Idempotency keys that dance in a row—
No duplicates slipping through, no double-submit woe! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding guards (validation, idempotency, capacity checks) to web purchase execution, with explicit issue closure references.
Linked Issues check ✅ Passed All three linked issues (#643, #644, #647) are comprehensively addressed: validatePurchaseRecommendation validates Term/Payment/Provider/Service/Count, idempotency key + duplicate detection prevents double-submit, and RecommendedCount enables capacity consistency checks.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the three guard objectives: validation helpers, idempotency logic, capacity checks, related tests, and frontend button-disable for UX consistency.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/643-purchase-guards

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

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/src/app.ts (2)

320-332: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle confirm-dialog rejection so the execute button can’t get stuck disabled.

If confirmDialog() throws/rejects, this path exits before the existing finally, leaving the button disabled until reload.

Suggested fix
-  const ok = await confirmDialog({
-    title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
-    body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
-    confirmLabel: 'Send for approval',
-    destructive: false,
-  });
+  let ok = false;
+  try {
+    ok = await confirmDialog({
+      title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
+      body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
+      confirmLabel: 'Send for approval',
+      destructive: false,
+    });
+  } catch (error) {
+    const err = error as Error;
+    showToast({ message: `Unable to open confirmation dialog: ${err.message}`, kind: 'error' });
+    if (executeBtn) {
+      executeBtn.disabled = false;
+      executeBtn.textContent = 'Send for Approval';
+    }
+    return;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app.ts` around lines 320 - 332, confirmDialog may throw/reject
and currently can exit early leaving executeBtn disabled; wrap the await
confirmDialog(...) call in a try/catch/finally (or use .catch()) so any
rejection is handled and executeBtn is re-enabled in the finally block before
returning. Specifically update the block around confirmDialog in
frontend/src/app.ts to catch errors from confirmDialog and ensure
executeBtn.disabled = false and executeBtn.textContent = 'Send for Approval' in
the finally, then bail out if the dialog was not confirmed or an error occurred.

421-546: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap fan-out execution in try/finally so button reset is guaranteed on all failures.

This function disables the button up-front but only reenables on explicit cancel or the normal success path. Any thrown error (e.g., loadDashboard() failure) can leave the button permanently disabled.

Suggested fix
 async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
   const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null;
-  if (executeBtn) {
-    executeBtn.disabled = true;
-    executeBtn.textContent = `Sending 0/${buckets.length}…`;
-  }
+  const resetExecuteBtn = () => {
+    if (executeBtn) {
+      executeBtn.disabled = false;
+      executeBtn.textContent = 'Send for Approval';
+    }
+  };
+  if (executeBtn) {
+    executeBtn.disabled = true;
+    executeBtn.textContent = `Sending 0/${buckets.length}…`;
+  }

-  const ok = await confirmDialog({
-    title: `Send ${buckets.length} bulk purchase${buckets.length === 1 ? '' : 's'} for approval?`,
-    body: `This will submit ${buckets.length} separate purchase request${buckets.length === 1 ? '' : 's'} and email ${buckets.length} approval request${buckets.length === 1 ? '' : 's'}. Each must be approved individually before its commitments are charged.`,
-    confirmLabel: 'Send all for approval',
-    destructive: false,
-  });
-  if (!ok) {
-    if (executeBtn) {
-      executeBtn.disabled = false;
-      executeBtn.textContent = 'Send for Approval';
-    }
-    return;
-  }
+  try {
+    const ok = await confirmDialog({
+      title: `Send ${buckets.length} bulk purchase${buckets.length === 1 ? '' : 's'} for approval?`,
+      body: `This will submit ${buckets.length} separate purchase request${buckets.length === 1 ? '' : 's'} and email ${buckets.length} approval request${buckets.length === 1 ? '' : 's'}. Each must be approved individually before its commitments are charged.`,
+      confirmLabel: 'Send all for approval',
+      destructive: false,
+    });
+    if (!ok) return;

-  // existing fan-out logic ...
-  await loadDashboard();
+    // existing fan-out logic ...
+    await loadDashboard();
+  } catch (error) {
+    const err = error as Error;
+    showToast({ message: `Failed to send bulk purchases for approval: ${err.message}`, kind: 'error' });
+  } finally {
+    resetExecuteBtn();
+  }
-
-  if (executeBtn) {
-    executeBtn.disabled = false;
-    executeBtn.textContent = 'Send for Approval';
-  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app.ts` around lines 421 - 546, The button disable/reset must be
guaranteed even if later awaits throw — move the re-enable/reset into a finally
block that executes after the user confirms; specifically, after the
confirmDialog ok check, wrap the fan-out and subsequent processing (the
promises/allSettled/results handling, closePurchaseModal/clear..., showToast
branches, openArcheraOfferModal('purchase'), and await loadDashboard()) in try {
... } finally { if (executeBtn) { executeBtn.disabled = false;
executeBtn.textContent = 'Send for Approval'; } } so any exception from
api.executePurchase, loadDashboard, or other async work still re-enables the
executeBtn; keep the existing early-cancel branch that re-enables and returns
as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 1253-1278: The current duplicate check in
findDuplicatePendingExecution (which reads pending rows via
h.config.GetPendingExecutions and computes purchaseIdempotencyKey) is racy
because the insert that persists a new pending execution occurs later; make the
idempotency check atomic by enforcing it at the DB level and handling it in the
same flow that creates executions. Add a persisted idempotency key column (or a
dedicated idempotency table) and a UNIQUE constraint on the idempotency key (and
creatorID/scope as needed), then change the create-pending-execution path (the
code that inserts the new pending execution) to either perform the lookup+insert
inside a single DB transaction/row lock or simply attempt the insert and, on
unique-constraint violation, return the existing execution; reference
purchaseIdempotencyKey, findDuplicatePendingExecution, GetPendingExecutions and
the creation/insert method used around Line ~1388 to locate where to implement
the DB constraint + upsert/unique-error handling.

In `@internal/api/validation.go`:
- Around line 510-529: validatePurchaseRecommendation currently normalizes
provider/payment into local vars but takes rec by value so changes aren't
persisted; change its signature to accept a pointer
(*config.RecommendationRecord) and, after computing provider :=
strings.ToLower(strings.TrimSpace(rec.Provider)) and payment :=
strings.ToLower(strings.TrimSpace(rec.Payment)), assign those canonical strings
back to rec.Provider and rec.Payment before returning; keep the existing
whitelist checks (purchasePaymentWhitelist, purchaseTermWhitelist) but ensure
all callers are updated to pass a pointer to RecommendationRecord.

---

Outside diff comments:
In `@frontend/src/app.ts`:
- Around line 320-332: confirmDialog may throw/reject and currently can exit
early leaving executeBtn disabled; wrap the await confirmDialog(...) call in a
try/catch/finally (or use .catch()) so any rejection is handled and executeBtn
is re-enabled in the finally block before returning. Specifically update the
block around confirmDialog in frontend/src/app.ts to catch errors from
confirmDialog and ensure executeBtn.disabled = false and executeBtn.textContent
= 'Send for Approval' in the finally, then bail out if the dialog was not
confirmed or an error occurred.
- Around line 421-546: The button disable/reset must be guaranteed even if later
awaits throw — move the re-enable/reset into a finally block that executes after
the user confirms; specifically, after the confirmDialog ok check, wrap the
fan-out and subsequent processing (the promises/allSettled/results handling,
closePurchaseModal/clear..., showToast branches,
openArcheraOfferModal('purchase'), and await loadDashboard()) in try { ... }
finally { if (executeBtn) { executeBtn.disabled = false; executeBtn.textContent
= 'Send for Approval'; } } so any exception from api.executePurchase,
loadDashboard, or other async work still re-enables the executeBtn; keep the
existing early-cancel branch that re-enables and returns as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d377b6b-8f77-40ff-a1fe-a710fc4afba1

📥 Commits

Reviewing files that changed from the base of the PR and between 45a92e7 and bd77fef.

📒 Files selected for processing (11)
  • frontend/src/__tests__/purchase-execution-toast.test.ts
  • frontend/src/api/types.ts
  • frontend/src/app.ts
  • frontend/src/recommendations.ts
  • frontend/src/types.ts
  • internal/api/handler_per_account_perms_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_guards_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/validation.go
  • internal/config/types.go

Comment thread internal/api/handler_purchases.go
Comment thread internal/api/validation.go Outdated
…in tx (#643)

Move the pending-execution scan inside the WithTx block so the duplicate
check and the INSERT are a single atomic operation, closing the TOCTOU
race that the pre-tx duplicatePurchaseResponse call could not prevent.

- Add GetPendingExecutionsTx to StoreInterface (SELECT ... FOR UPDATE)
- Extract scanExecutionRows helper to share row-scan logic between the
  pool path and the new tx path
- Extract matchDuplicateInList to keep persistExecutionAndSuppressions
  and executePurchase under the gocyclo threshold
- Add derefStringOrEmpty to eliminate the nil-check branch in executePurchase
- Change validatePurchaseRecommendation to *RecommendationRecord receiver
  so provider/service/payment normalisation is written back to the caller
- Add GetPendingExecutionsTx fallback to all six test mock types

Closes #643
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant