Skip to content

fix(purchases): handle empty plan_id on direct-execute approval (closes #395) - #412

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-395-empty-planid-approve-crash
May 14, 2026
Merged

fix(purchases): handle empty plan_id on direct-execute approval (closes #395)#412
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-395-empty-planid-approve-crash

Conversation

@cristim

@cristim cristim commented May 14, 2026

Copy link
Copy Markdown
Member

Summary

  • P0 fix for direct-execute approval crash introduced by fix(purchases): approve now executes synchronously and parallel across recs (closes #372) #373.
  • executePurchase short-circuits the plan/accounts fetch when PlanID is empty, synthesises a *config.PurchasePlan{Name: "Direct purchase"} placeholder for the downstream history-record write + confirmation email, and falls through to the single-account legacy path.
  • updatePlanProgress gains a top-level if planID == "" { return nil } guard so the post-execution hook stops hitting the same SQLSTATE 22P02.
  • SavePurchaseExecution (line 719) and SavePurchaseHistory (line 1042) already pass NULL for empty PlanID on the UUID column, so the persistence layer is unchanged.

Root cause

After #373 made approve synchronous, executePurchase runs inline from ApproveAndExecute. For purchases created via the Opportunities "Purchase" button there is no plan and exec.PlanID == "". GetPurchasePlan(ctx, "") rejects on the UUID column with ERROR: invalid input syntax for type uuid: "" (SQLSTATE 22P02). The execution row is left in approved status without ever firing.

Test plan

  • go test ./internal/purchase/... — 105 pass, including new regression TestManager_ApproveAndExecute_EmptyPlanID.
  • go test ./internal/api/... — 1081 pass.
  • go test ./internal/... — 3700 pass across 24 packages.
  • go vet ./... clean.
  • Regression guard asserts GetPurchasePlan / GetPlanAccounts / UpdatePurchasePlan are never called for empty-PlanID executions, and that the execution lands at status=completed.

Closes #395

#395)

PR #373 made approve synchronous, so executePurchase now runs inline from
ApproveAndExecute. For direct-execute purchases created via the
Opportunities "Purchase" button there is no associated plan and
exec.PlanID is "". GetPurchasePlan(ctx, "") hits the UUID-typed
purchase_plans.id column and crashes with SQLSTATE 22P02, leaving the
execution stuck in approved status without ever running the purchase.

executePurchase now short-circuits the plan/accounts fetch when PlanID
is empty, synthesises a placeholder PurchasePlan{Name: "Direct purchase"}
for the downstream history-record write + confirmation email, and falls
through to the single-account path. updatePlanProgress gains a guard at
the top so the post-execution progress update doesn't hit the same error.

SavePurchaseExecution (store_postgres.go:719) and SavePurchaseHistory
(store_postgres.go:1042) already handle empty PlanID by passing NULL to
the nullable column, so the persistence layer is untouched.

Adds TestManager_ApproveAndExecute_EmptyPlanID as a regression guard that
asserts the empty-PlanID approval path never calls GetPurchasePlan /
GetPlanAccounts / UpdatePurchasePlan and lands the execution at status
"completed".
@coderabbitai

coderabbitai Bot commented May 14, 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 3 minutes and 9 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: 26257254-c806-4d1e-8809-68a5a1970092

📥 Commits

Reviewing files that changed from the base of the PR and between 87070ad and 484e43a.

📒 Files selected for processing (2)
  • internal/purchase/approvals_test.go
  • internal/purchase/execution.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-395-empty-planid-approve-crash

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

@cristim cristim added priority/p0 Drop everything; same-day fix severity/high Significant harm urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/bug Defect triaged Item has been triaged labels May 14, 2026
@cristim

cristim commented May 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 14, 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 merged commit 8738d60 into feat/multicloud-web-frontend May 14, 2026
4 checks passed
cristim added a commit that referenced this pull request May 19, 2026
#454)

* fix(purchases): reconstruct typed Details pointer for every service (closes #453)

After #373 made approve synchronous, every AWS purchase from the dashboard
failed with `invalid service details for <Service>`. executeSinglePurchase
assigned a value-typed common.DatabaseDetails (and only when rec.Engine was
non-empty); every AWS findOfferingID type-asserts a *pointer*, so the
assertion failed and the error propagated up as "some purchases failed:
[<resource>: purchase failed: failed to find offering: invalid service
details for <Service>]" for every service.

This change preserves the full ServiceDetails payload across the
dashboard → DB → executePurchase boundary so per-rec platform, tenancy,
AZ-config, plan-type etc. round-trip correctly:

- pkg/common/service_details_codec.go: new Marshal/Decode helpers backed by
  a per-service-slug table. Empty payload on a known slug yields a zero-
  valued typed pointer (legacy-row graceful degradation); unknown slug
  returns nil; malformed JSON surfaces as an error.
- internal/config/types.go: RecommendationRecord gains a json.RawMessage
  Details column persisted alongside the existing engine column. Stored as
  raw JSON because internal/config must not import pkg/common.
- internal/scheduler/scheduler.go (convertRecommendations): marshals the
  per-rec ServiceDetails on collection. Marshal errors are logged and the
  row is persisted without Details so the graceful-degradation path
  applies.
- internal/purchase/execution.go (executeSinglePurchase): replaces the
  value-typed DatabaseDetails block with DecodeServiceDetailsFor +
  applyEngineFallback. The fallback backfills Engine from the persisted
  column onto DB/Cache Details so legacy rows that lacked the full payload
  don't silently mis-purchase as the default engine.

Per-service map of what Details type is now handed to the cloud client:
- EC2          → *common.ComputeDetails       (Platform/Tenancy/Scope)
- RDS          → *common.DatabaseDetails      (Engine/AZConfig/InstanceClass)
- ElastiCache  → *common.CacheDetails         (Engine/NodeType)
- OpenSearch   → *common.SearchDetails        (forward-compat; client doesn't assert)
- Redshift     → *common.DataWarehouseDetails (forward-compat; client doesn't assert)
- MemoryDB     → nil                          (no *Details type; client doesn't assert)
- Savings Plans (all variants) → *common.SavingsPlanDetails (PlanType/HourlyCommitment)

Azure / GCP service clients don't type-assert rec.Details (they read
rec.ResourceType / rec.Term / rec.Count directly), so they're unaffected
by either the bug or the fix.

Independent of #412 (empty plan_id, merged); the two fixes compose to
unblock the full dashboard approve flow after #373.

Tests:
- internal/purchase/coverage_extra_test.go: TestManager_ExecuteSinglePurchase_LegacyEmptyDetails
  table-drives EC2/RDS/ElastiCache through executePurchase with an empty
  Details payload, captures the rec.Details handed to the mock service
  client, and asserts the concrete pointer type plus Engine backfill where
  applicable. TestApplyEngineFallback pins the helper's per-type behaviour.
- pkg/common/service_details_codec_test.go: round-trip + legacy-empty +
  unknown-service + malformed-JSON + legacy-dash-form SP coverage.

Test counts: go test ./... → 4454 pass across 37 packages. go vet ./...
clean.

* fix(purchases): apply CR nit — bytes.Equal for JSON null check

Replace `string(raw) == "null"` with `bytes.Equal(raw, jsonNullBytes)` in
the ServiceDetails codec to avoid the string conversion allocation on the
read-back path (every purchase routes through DecodeServiceDetailsFor).
Same treatment for MarshalServiceDetails' nil-pointer-folding step.

CodeRabbit nit on PR #454. No behaviour change.
@cristim
cristim deleted the fix/issue-395-empty-planid-approve-crash branch June 3, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant