Skip to content

fix(planned-purchases): Disable plan flips plan.enabled=false atomically - #781

Closed
cristim wants to merge 0 commit into
feat/multicloud-web-frontendfrom
fix/774-disable-plan-atomic
Closed

cristim wants to merge 0 commit into
feat/multicloud-web-frontendfrom
fix/774-disable-plan-atomic

Conversation

@cristim

@cristim cristim commented May 27, 2026 •

Copy link
Copy Markdown
Member

Summary

QA Planned 6.4: clicking "Disable plan" next to a scheduled purchase removed the scheduled execution but left the plan's toggle ON on the Plans page. State was inconsistent.

Root cause

deletePlannedPurchase (internal/api/handler_purchases.go) called TransitionExecutionStatus to cancel the scheduled execution but never read the returned execution's PlanID and never set plan.Enabled = false. The frontend already called loadPlans() after disable, so the stale toggle was purely a backend omission.

Fix

  • Backend captures the execution returned by TransitionExecutionStatus, reads its PlanID, fetches the parent plan via GetPurchasePlan, and calls UpdatePurchasePlan with Enabled = false.
  • Idempotent: skips the update if the plan is already disabled.
  • Frontend test pins that loadPlans() fires after disable (toggle reflects new state).

Files changed

  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • frontend/src/__tests__/plans.test.ts

Test plan

  • TestHandler_deletePlannedPurchase_DisablesPlan asserts UpdatePurchasePlan called with Enabled=false and post-call plan reads Enabled=false.
  • TestHandler_deletePlannedPurchase_AlreadyDisabledPlan asserts UpdatePurchasePlan is NOT called when plan is already disabled (idempotent path).
  • Both use t.Cleanup(mockStore.AssertExpectations) per feedback_mock_assert_expectations memory.
  • 1337 Go tests + 98 frontend plans tests pass.
  • Manual: click Disable plan from a scheduled-purchase row; confirm both the scheduled purchase disappears AND the plan toggle is OFF on the Plans page.

Design notes (commented on #774 separately)

  • The confirm dialog says "paused" but the action sets enabled=false -- label inconsistency to consider.
  • Multi-step ramp-plan scope: should disabling cancel all future steps too?
  • "Disable plan" vs "Pause" semantics confusion -- product input needed.

Closes #774.

Summary by CodeRabbit

  • Bug Fixes

    • Planned purchase cancellation now automatically disables the associated purchase plan, preventing orphaned active plans when their executions are canceled.
  • Tests

    • Extended test coverage for planned purchase cancellation scenarios, including verification that the parent purchase plan is disabled and that already-disabled plans are handled correctly.

Review Change Stack

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect labels May 27, 2026
@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR fixes state inconsistency (#774) where disabling a scheduled purchase removed the purchase from the list but left the parent plan's toggle enabled. The backend handler now atomically disables the parent plan when cancelling a scheduled purchase, backend tests validate the disabling logic for both enabled and pre-disabled plans, and the frontend test expects the plan list to refresh.

Changes

Plan State Consistency When Disabling Scheduled Purchases

Layer / File(s) Summary
Handler plan disabling logic
internal/api/handler_purchases.go
After cancelling the scheduled execution, deletePlannedPurchase loads the parent PurchasePlan and sets Enabled = false (only if it was true), with error handling for failed cancels, missing plans, and update failures.
Backend regression tests
internal/api/handler_purchases_test.go
Two new test functions: one verifies UpdatePurchasePlan is called when disabling an enabled plan; the other verifies the handler succeeds without updating a plan that is already disabled.
Frontend test plan refresh expectation
frontend/src/__tests__/plans.test.ts
The disable-plan action test now asserts that api.getPlans is called after deletion to refresh the Plans page UI state.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • LeanerCloud/CUDly#207: Switches the dashboard "Cancel" button to call api.deletePlannedPurchase(planId), complementing this PR's backend behavior change to disable the parent plan on cancellation.

Poem

🐰 A rabbit hops through planned-purchase affairs,
Where toggle and schedule must both now align,
When cancellation rings out, the plan must declare,
"I'm disabled too!"—state stays perfectly fine.
No stray toggles left standing when purchases decline!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% 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 accurately summarizes the main fix: disabling a plan now atomically flips plan.enabled=false, which is the core change addressing issue #774.
Linked Issues check ✅ Passed The changes meet all coding requirements from issue #774: handler disables the plan atomically via UpdatePurchasePlan after canceling execution, new regression tests verify both disabled and already-disabled paths, and frontend test confirms loadPlans is called post-disable.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the state inconsistency in issue #774: backend atomicity, idempotent handling, and frontend refresh assertion. No unrelated changes detected.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/774-disable-plan-atomic

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

@cristim

cristim commented May 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 27, 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 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 28, 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 (1)
frontend/src/__tests__/plans.test.ts (1)

804-821: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the refresh assertion to avoid a false positive.

This test already has prior getPlans calls from setup, so toHaveBeenCalled() does not prove disable triggered a reload.

🔧 Proposed fix
     test('disable action deletes planned purchase with confirmation', async () => {
       (api.deletePlannedPurchase as jest.Mock).mockResolvedValue({});
       (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
       (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
       window.confirm = jest.fn().mockReturnValue(true);

       const disableBtn = document.querySelector('[data-action="disable"]') as HTMLButtonElement;
+      (api.getPlans as jest.Mock).mockClear();
       disableBtn?.click();

       await new Promise(resolve => setTimeout(resolve, 50));

       expect(window.confirm).toHaveBeenCalled();
       expect(api.deletePlannedPurchase).toHaveBeenCalledWith('purchase-1');
       // Issue `#774`: after disable the Plans page must refresh so the toggle
       // reflects the backend's new enabled=false. getPlans is the API call
       // that loadPlans() fires to repopulate the Plans list.
-      expect(api.getPlans).toHaveBeenCalled();
+      expect(api.getPlans).toHaveBeenCalledTimes(1);
     });
🤖 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/__tests__/plans.test.ts` around lines 804 - 821, The test's
assert that api.getPlans was called is flaky because earlier setup already
invoked getPlans; before simulating the disable action (the click on the element
found via document.querySelector('[data-action="disable"]') and window.confirm),
either clear the getPlans mock (api.getPlans.mockClear()) or record its current
call count and then assert that api.getPlans was invoked one additional time
(expect(api.getPlans).toHaveBeenCalledTimes(prevCount + 1)); use
api.deletePlannedPurchase and api.getPlans mocks and the disableBtn click to
verify the reload deterministically.
🤖 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 274-280: The error handling after calling h.config.GetPurchasePlan
should preserve "not found" semantics: instead of wrapping every error as a
server error, detect the not-found case (e.g., using errors.Is(err, ErrNotFound)
or comparing to the specific sentinel your config layer returns, or to
sql.ErrNoRows) and return a client 404 via NewClientError(404,
fmt.Sprintf("disable plan: plan %s not found", cancelled.PlanID)); otherwise
keep the existing server-error wrap (fmt.Errorf("disable plan: failed to fetch
plan %s: %w", cancelled.PlanID, err)). Also keep the existing nil-plan check
that returns NewClientError(404, ...). This change should be applied around the
GetPurchasePlan call in the handler where plan, err :=
h.config.GetPurchasePlan(ctx, cancelled.PlanID) is invoked.
- Around line 264-285: The cancel flow can leave plan.enabled=true if
TransitionExecutionStatus succeeds but the subsequent
GetPurchasePlan/UpdatePurchasePlan steps fail and a retry hits
TransitionExecutionStatus again returning 409; to fix, make the disable path
idempotent by continuing to the plan-disable steps when
TransitionExecutionStatus reports a conflict: call h.config.GetExecution(ctx,
executionID) (or otherwise fetch the execution) when TransitionExecutionStatus
returns a 409/conflict to obtain the execution's PlanID, treat that as the
cancelled result (handle nil safely), then run the existing
GetPurchasePlan(planID) and UpdatePurchasePlan(plan) logic (only flip
plan.Enabled to false if true) so retries will still ensure the plan is disabled
even if the transition itself reports already-done; keep returning original
errors for non-conflict failures.

---

Outside diff comments:
In `@frontend/src/__tests__/plans.test.ts`:
- Around line 804-821: The test's assert that api.getPlans was called is flaky
because earlier setup already invoked getPlans; before simulating the disable
action (the click on the element found via
document.querySelector('[data-action="disable"]') and window.confirm), either
clear the getPlans mock (api.getPlans.mockClear()) or record its current call
count and then assert that api.getPlans was invoked one additional time
(expect(api.getPlans).toHaveBeenCalledTimes(prevCount + 1)); use
api.deletePlannedPurchase and api.getPlans mocks and the disableBtn click to
verify the reload deterministically.
🪄 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: 81de6670-4374-4007-8018-3b4869fceefb

📥 Commits

Reviewing files that changed from the base of the PR and between d986b4d and a3f96b5.

📒 Files selected for processing (3)
  • frontend/src/__tests__/plans.test.ts
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go

Comment thread internal/api/handler_purchases.go Outdated
Comment on lines +264 to +285
cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled")
if err != nil {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err))
}

// Set the parent plan's enabled flag to false so the Plans page toggle
// reflects the disable action immediately. Issue #774: previously the
// execution was cancelled but plan.enabled was left true, causing
// inconsistent state between the Scheduled Purchases and Plans views.
if cancelled.PlanID != "" {
plan, err := h.config.GetPurchasePlan(ctx, cancelled.PlanID)
if err != nil {
return nil, fmt.Errorf("disable plan: failed to fetch plan %s: %w", cancelled.PlanID, err)
}
if plan == nil {
return nil, NewClientError(404, fmt.Sprintf("disable plan: plan %s not found", cancelled.PlanID))
}
if plan.Enabled {
plan.Enabled = false
if err := h.config.UpdatePurchasePlan(ctx, plan); err != nil {
return nil, fmt.Errorf("disable plan: failed to update plan %s: %w", cancelled.PlanID, err)
}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make disable flow retry-safe after partial success.

If execution cancellation succeeds but plan fetch/update fails, a retry will return 409 at the transition step and never re-run the disable path, leaving plan.enabled=true indefinitely.

🔧 Proposed fix (idempotent retry path)
 	cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled")
 	if err != nil {
-		return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err))
+		// Retry-safe: if a prior attempt already cancelled the execution,
+		// continue with the plan-disable step instead of hard-failing.
+		existing, getErr := h.config.GetExecutionByID(ctx, executionID)
+		if getErr == nil && existing != nil && existing.Status == "cancelled" {
+			cancelled = existing
+		} else {
+			return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err))
+		}
 	}
🤖 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 `@internal/api/handler_purchases.go` around lines 264 - 285, The cancel flow
can leave plan.enabled=true if TransitionExecutionStatus succeeds but the
subsequent GetPurchasePlan/UpdatePurchasePlan steps fail and a retry hits
TransitionExecutionStatus again returning 409; to fix, make the disable path
idempotent by continuing to the plan-disable steps when
TransitionExecutionStatus reports a conflict: call h.config.GetExecution(ctx,
executionID) (or otherwise fetch the execution) when TransitionExecutionStatus
returns a 409/conflict to obtain the execution's PlanID, treat that as the
cancelled result (handle nil safely), then run the existing
GetPurchasePlan(planID) and UpdatePurchasePlan(plan) logic (only flip
plan.Enabled to false if true) so retries will still ensure the plan is disabled
even if the transition itself reports already-done; keep returning original
errors for non-conflict failures.

Comment thread internal/api/handler_purchases.go Outdated
Comment on lines +274 to +280
plan, err := h.config.GetPurchasePlan(ctx, cancelled.PlanID)
if err != nil {
return nil, fmt.Errorf("disable plan: failed to fetch plan %s: %w", cancelled.PlanID, err)
}
if plan == nil {
return nil, NewClientError(404, fmt.Sprintf("disable plan: plan %s not found", cancelled.PlanID))
}

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return 404 when parent plan lookup is not found.

GetPurchasePlan not-found errors are currently wrapped and surfaced as generic server errors. This path should map to a client 404 consistently.

🔧 Proposed fix (preserve not-found semantics)
 		plan, err := h.config.GetPurchasePlan(ctx, cancelled.PlanID)
 		if err != nil {
-			return nil, fmt.Errorf("disable plan: failed to fetch plan %s: %w", cancelled.PlanID, err)
+			if IsNotFoundError(err) {
+				return nil, NewClientError(404, fmt.Sprintf("disable plan: plan %s not found", cancelled.PlanID))
+			}
+			return nil, fmt.Errorf("disable plan: failed to fetch plan %s: %w", cancelled.PlanID, err)
 		}
 		if plan == nil {
 			return nil, NewClientError(404, fmt.Sprintf("disable plan: plan %s not found", cancelled.PlanID))
 		}
🤖 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 `@internal/api/handler_purchases.go` around lines 274 - 280, The error handling
after calling h.config.GetPurchasePlan should preserve "not found" semantics:
instead of wrapping every error as a server error, detect the not-found case
(e.g., using errors.Is(err, ErrNotFound) or comparing to the specific sentinel
your config layer returns, or to sql.ErrNoRows) and return a client 404 via
NewClientError(404, fmt.Sprintf("disable plan: plan %s not found",
cancelled.PlanID)); otherwise keep the existing server-error wrap
(fmt.Errorf("disable plan: failed to fetch plan %s: %w", cancelled.PlanID,
err)). Also keep the existing nil-plan check that returns NewClientError(404,
...). This change should be applied around the GetPurchasePlan call in the
handler where plan, err := h.config.GetPurchasePlan(ctx, cancelled.PlanID) is
invoked.

cristim added a commit that referenced this pull request May 28, 2026
…an + deterministic test

On a retry where TransitionExecutionStatus returns ErrExecutionNotInExpectedStatus
(the cancel already landed in a prior attempt), the handler now calls
GetExecutionByID to recover the PlanID and proceeds to disable the plan.
This makes the disable path idempotent: plan.enabled is always set to false
regardless of how many times the endpoint is called.

Extract the plan-disable logic into disablePlan so it can be called from both
the normal path and the conflict-retry path. Replace the dead plan==nil guard
with errors.Is(err, config.ErrNotFound) so not-found maps to 404 rather than 500.

Add two backend regression tests covering the conflict-retry path (enabled plan
gets disabled, already-disabled plan is a no-op). Fix the frontend assertion to
call (api.getPlans as jest.Mock).mockClear() before the disable click so the
subsequent toHaveBeenCalledTimes(1) assertion only counts the reload triggered
by the disable action itself, not prior setup calls.

Addresses CR findings on PR #781 (1 major + 2 potential issues).
@cristim

cristim commented May 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 28, 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 closed this May 28, 2026
@cristim
cristim force-pushed the fix/774-disable-plan-atomic branch from a3f96b5 to 3f56912 Compare May 28, 2026 12:54
@cristim
cristim deleted the fix/774-disable-plan-atomic branch June 3, 2026 21:54

This branch was previously deployed

1 inactive deployment
dev — 3f569121 Deployed May 28, 2026 by cristim via Test Deployment #459
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant