feat(purchases): in-app revocation within free-cancel window (closes #290) - #804
Conversation
|
@coderabbitai review |
|
Warning Review limit reached
More reviews will be available in 2 minutes and 9 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughAdds in-app revoke for Azure completed purchases plus a Gmail-style pre-fire delay that schedules approved purchases, stamps revoke windows, implements Azure CalculateRefund/Return revoke flow with TOCTOU and in-flight reconciliation, extends DB/store/schema and scheduler to fire scheduled executions and finalize revocations, updates frontend revoke UI/permissions/API, and aligns tests/mocks across suites. ChangesPurchase Revocation & Pre-Fire Delay Feature
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
✨ Finishing Touches🧪 Generate unit tests (beta)
|
✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
… complexity; regenerate permissions on PR #804
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
… complexity; regenerate permissions on PR #804
|
Rebased onto
Fixed CI red lights from the previous push:
Free-cancel window enforcement (
@coderabbitai review |
|
🧠 Learnings used✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
… complexity; regenerate permissions on PR #804
378a7de to
a4e9e33
Compare
|
Triggering a fresh incremental review now. 🐇 🔐 ✅ Action performedReview finished.
|
…t gate (#290) The gocyclo pre-commit hook (threshold 10) failed CI on four functions the #290 feature work grew past the limit. Split each into focused helpers with no behavior change: - calculateAzureRevoke (21): extract validateAzureRevokeRequest (auth + load + authorize + window/ID validation, itself split into azureRevokeWindowAndIDs) and extractAzureRefundQuote. - callAzureReturn (21): extract azureCalculateRefund (CalculateRefund + parse), handleAzureReturnError (clear in-flight + status mapping), and persistAzureRevocation (MarkPurchaseRevoked retry + 207 result). - annotateHistoryRowByStatus (12): move the in-flight / audit-gap cases into annotateInFlightOrAuditGapRow. - dispatchTask (11): switch to a map-based dispatch. gocyclo now reports nothing over 10; full internal test suite green (except the pre-existing CSRF flake that also fails on base).
|
@coderabbitai review Follow-up: the
|
|
🐇 🔧 ✅ Action performedReview finished.
|
…closes #1009) Add transitioned_by/transitioned_at columns to purchase_executions, ri_exchange_history, and account_registrations (migration 000066). Thread an optional actor *string through all three TransitionStatus methods so human-initiated transitions record the session user UUID while system-initiated paths (reaper, scheduler, token-based approval) pass nil. Guard non-UUID reviewer IDs (e.g. "admin-api-key") via validUUIDPtrOrNil before using as a FK value. Mirrors the CancelExecutionAtomic(actor) pattern from #804.
Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left two integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…loses #291) #804 (#290) added a session-authed in-app revokePurchase plus handler_purchases_revoke.go; #291 added a token-based email one-click revoke plus a public GET/POST /api/purchases/revoke/{id} route. Two methods named revokePurchase collided after #804 merged. Unify both into a single revokePurchase(ctx, req, id, token) entry point in handler_purchases_revoke.go that dispatches on token presence and the row's state, mirroring how approvePurchase / cancelPurchase already handle token-OR-session: - GET (any route): render the HTML confirmation form so email prefetchers cannot auto-trigger a mutation; only POST mutates. - purchase_execution, status completed/partially_completed: the #291 post-execution revoke, now revokeCompletedExecution. token-OR-session matrix; the email one-click link (token) and the dashboard Revoke button (session) both land here. Token path keeps the contact-email authz, the token expiry + 24h window + constant-time compare; session path keeps the cancel-any/cancel-own RBAC. - purchase_execution, status scheduled: #290 free pre-fire cancel, session-only, scheduled-CAS arbitrated. - pending/notified: friendly 409 directing the caller to Cancel. - no execution row: 404 for the token (email) path; otherwise the session-only Azure reservation Return in purchase_history (#290). Auth is fail-closed on every path: a nil auth service returns 403 before any lookup, so neither the token nor the session path can be served unauthenticated, and neither bypasses the window/state checks. Both routes are kept and point at the unified handler: /api/purchases/{id}/revoke (AuthUser, session) and the public /api/purchases/revoke/{id} (AuthPublic, token). Email package keeps both SendPurchaseScheduledNotification and SendPurchaseExecutedNotification; NotificationData merges the two field sets, deduping RevocationWindowClosesAt. No migration is added (#291 adds none; base top is 000073). Tests reconciled: both handler_purchases_revoke_test.go (#804) and the revoke tests in handler_purchases_test.go (#291) adapt to the unified signature; token scenarios (valid/invalid/expired, GET-no-mutate, window-expired) and session scenarios (fail-closed, creator-scope, scheduled-CAS race, Azure return) are all still asserted.
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…s SELECT
GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.
The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.
Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.
Closes #1247
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…s SELECT
GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.
The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.
Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.
Closes #1247
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…s SELECT
GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.
The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.
Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.
Closes #1247
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070).
…#292) (#808) * feat(marketplace): sell/cancel Standard RIs on AWS Marketplace - backend (closes #292) Add sell-on-Marketplace support for Standard Reserved Instances: - migration 000060: add offering_class, listing_id, listing_state to purchase_history - auth: sell-any and sell-own actions; sell-own added to DefaultUserPermissions - config: extend PurchaseHistoryRecord, StoreInterface with GetPurchaseHistoryByPurchaseID + UpdatePurchaseHistoryListing - ec2 client: CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing - api: handler_marketplace.go with marketplaceList/Cancel, authorizeSessionSell (sell-any/sell-own RBAC), default price schedule (95% of residual value) - router: POST /api/purchases/{id}/marketplace-list + /marketplace-cancel routes - all tests updated for the new interface methods and permission count * feat(marketplace): sell/cancel Standard RIs on AWS Marketplace - frontend (closes #292) Add Sell on Marketplace / Cancel listing buttons to purchase history rows: - types.ts: extend HistoryPurchase with offering_class, listing_id, listing_state - api/purchases.ts: createMarketplaceListing + cancelMarketplaceListing; re-export from api/index.ts - history.ts: canSellOnMarketplace / canCancelMarketplaceListing predicates; renderActionCell renders Sell/Cancel buttons for Standard RI completed rows; wireRowActionHandlers wires confirm-dialog + API + toast + reload for both buttons * fix(history): address CR wave4 frontend marketplace findings - canSellOnMarketplace: gate on remaining term >= 1 month computed from purchase timestamp + total term; matured Standard RIs no longer show Sell - sell click handler: open pricing/schedule modal before calling createMarketplaceListing so users see RI summary, default list price, and 12% fee breakdown before confirming - lineage actions cell: build trailingActions[] first then combine with lineage[] so Sell/Cancel buttons render on retry-descendant rows * fix(marketplace): address CR wave4 Go handler findings - Compute actual remaining months from purchase timestamp and total term via computeRemainingMonths; removes overpricing of older RIs in the default price schedule (was using full contract term) - Map AWS errors via mapAWSMarketplaceError: inspect smithy.APIError code and fault; client-fault errors return 4xx instead of blanket 502 - On DB failure after successful listing creation, attempt compensating rollback via CancelMarketplaceListing to prevent AWS/DB desync; return internal error rather than false success to the caller - Same DB-failure fix for cancel handler: return error on UpdatePurchaseHistoryListing failure so the caller knows the state is out of sync * fix(migrations): add settlement columns to 000060 marketplace migration Add listed_at, listing_price_schedule, listing_proceeds_received, and listing_fee_paid columns for marketplace settlement tracking. All nullable, consistent with existing offering_class/listing_id/listing_state nullability. Down migration updated to drop the new columns. * fix(ec2): validate non-empty listing ID from CreateReservedInstancesListing Return an error when AWS returns a listing with an empty ReservedInstancesListingId rather than propagating a blank ID that would silently make rollback and describe calls fail. * fix(pr-808): compile + CR findings + pre-commit Repair the stalled marketplace sell/cancel work: - Add the three missing marketplace methods to MockEC2Client so the ec2 package compiles against the extended EC2API interface (CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing). - Extract validateMarketplaceListRequest from marketplaceList to bring its cyclomatic complexity back under the gocyclo threshold. - Regenerate frontend/src/permissions.generated.ts to include the new sell-own:purchases permission (pre-commit codegen check). - Harden Describe/Cancel marketplace paths to fall back to the caller-supplied listing ID when AWS omits ReservedInstancesListingId, so downstream persistence never stores an empty ID (CR finding). * fix(marketplace): adapt to nullable PurchaseHistoryRecord.MonthlyCost After rebasing onto feat/multicloud-web-frontend, base PR #258 made PurchaseHistoryRecord.MonthlyCost a *float64 (nullable). The marketplace default price-schedule path passed it as a float64. Dereference it nil-safely at the call site, treating an absent monthly breakdown as a zero recurring contribution to residual value (per the nullable-not-zero convention: nil means "no breakdown recorded", which contributes nothing to the residual list price). * refactor(config): extract scanPurchaseHistoryRow to fix gocyclo budget queryPurchaseHistory reached cyclomatic complexity 11 (budget 10) after the nullable marketplace columns (offering_class, listing_id, listing_state) and the nullable monthly_cost handling were added to the per-row scan. Pull the scan plus nullable-column reconciliation into scanPurchaseHistoryRow so the parent function drops back under the limit. Behavior is unchanged. * fix(api/marketplace): prorate upfront cost to remaining term in default schedule When the caller omits a price schedule, the default was computing total value as (full_upfront + recurring * remaining_months). For an older RI this overprices the upfront component because only (remaining/original) of the upfront has not yet been amortized. Change to: upfront_remaining = upfront_cost * (remaining / original_term) total_value = upfront_remaining + recurring * remaining_months Pass row.Term as originalTerm through resolveMarketplacePriceSchedule. Addresses CR finding on handler_marketplace.go. * fix(marketplace): multi-count listing, handler tests, descope poller columns (closes #292 partial) Address adversarial-review gaps on the RI Marketplace sell/cancel PR: - Multi-count fix: CreateMarketplaceListing now lists every RI in the row (purchase_history.count) instead of a hardcoded InstanceCount=1, so a row of N Standard RIs lists all N. The EC2 client rejects a non-positive count before the outbound call; the handler passes row.Count (floored at 1 for legacy rows). Adds EC2 client tests for create (multi-count + guards), describe, and cancel via the SDK mock. - Tests: add handler_marketplace_test.go covering convertible -> 400, sell-own allow + deny (account scoping), duplicate active listing -> 409, AWS error mapping (client fault -> 400, server/unknown -> 502), DB-failure compensating rollback, and default-schedule proration math. - Descope poller: migration 000060 keeps only the columns the implemented flow reads/writes (offering_class, listing_id, listing_state). The settlement/poller columns (listed_at, listing_price_schedule, listing_proceeds_received, listing_fee_paid) are removed so the schema never carries columns nothing populates; they land with the poller in the #292 follow-up (#966). - Repair a stale Session.Role="admin" reference in a pre-existing purchases test (removed in the group-only auth migration) so the api test package compiles after rebasing onto the current base. * fix(migrations): renumber to 000065 to avoid base conflict 000060 is taken by 000060_cleanup_universal_plans on base. * fix(migrations): renumber marketplace migration to 000068 to clear base conflict The marketplace listing migration was numbered 000065, colliding with 000065_enforce_min_one_admin which has since landed on feat/multicloud-web-frontend. The check-migration-conflicts pre-commit hook fails on the merge ref with "Duplicate migration number(s): 000065". Renumber to 000068 (one past base's highest, 000067) so the number is unique on the merge ref, and update the two stale in-file comment references from 000060 to 000068. * fix(marketplace): reconcile mock + permission tests and gocyclo after #804 rebase Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation base (#804) left integration gaps that only surface once both feature sets coexist: - internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID twice: #804 added the func-override version and the marketplace commit added a no-op stub. Drop the marketplace stub; keep the override and the unique UpdatePurchaseHistoryListing stub so the mock builds. - frontend permissions.test.ts asserted the user role grants 12 verbs; the merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus sell-own from #292). Add sell-own:purchases to the expected set. - store_postgres.go: merging the revocation columns (#290) into the marketplace gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID back over the cyclomatic budget. Extract the shared NULL reconciliation into a purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and the NULL handling lives in one place. The conflict resolution itself (handlers, store SELECT/scan column order, auth defaults, mocks, frontend history buttons) was applied additively during the rebase; both feature sets are independent (marketplace sell/cancel vs revocation). Marketplace migration stays at 000068 (free gap vs the new base, which tops out at 000070). * fix(marketplace): gate sell actions on sell verbs and prorate default price (CR #808) Address CodeRabbit findings on the issue #292 marketplace sell/cancel flow: - Gate canSellOnMarketplace / canCancelMarketplaceListing on the sell-own / sell-any (or admin) verbs instead of bare sign-in, so the UX gate matches the backend authorizeSessionSell and avoids frontend/backend auth drift. Adds sell-own / sell-any to the Action union, mirroring the backend ActionSellOwn / ActionSellAny constants. - Prorate the upfront cost to its residual value in the sell modal's default price estimate (upfront * remaining/term), matching the backend resolveMarketplacePriceSchedule. Using the full upfront overstated the listing value for partially elapsed RIs. Also correct stale "migration 000060" comments (the columns ship in 000068) and add pgxmock coverage for GetPurchaseHistoryByPurchaseID asserting its distinct 25-column scan order (revocation_in_flight at position 22). * fix(marketplace): name fee/discount constants and restore gocyclo baseline - Introduce awsMarketplaceFeePercent (12), awsMarketplaceNetFactor (0.88), and awsMarketplaceBuyerDiscountFactor (0.95) as named Go constants in handler_marketplace.go; replace the matching TS literals with AWS_MARKETPLACE_FEE_PERCENT / AWS_MARKETPLACE_NET_FACTOR / AWS_MARKETPLACE_BUYER_DISCOUNT in history.ts. Eliminates silent ratios on a money-affecting display path per the no-hardcoded-magic-values rule. - Restore store_postgres_recommendations.go to main's lower-complexity form (recEffectiveSavingsPct + recOnDemandBaseline split); the rebase picked up an inlined variant from an earlier PR commit that cyclomatic-scored 12. * refactor(api): derive awsMarketplaceNetFactor from fee percent Replace the hardcoded 0.88 literal with a computed constant expression (1 - awsMarketplaceFeePercent/100.0) so the net factor stays in sync with the fee percent automatically. The Go constant evaluator uses arbitrary precision, so the resulting float64 value is identical to the literal it replaces; no money-math change. * fix(marketplace): guard concurrent RI listing creates with atomic slot claim Two concurrent marketplace-list requests for the same RI could both pass the read-only listing_state check in validateMarketplaceListRequest and both call AWS CreateReservedInstancesListing (each with a fresh ClientToken, so AWS does not dedup them), leaving two live listings for one RI and overwriting the DB row. Reserve the listing slot with a single atomic conditional UPDATE (ClaimMarketplaceListingSlot, modeled on FlipPurchaseRevocationInFlight) before the AWS call so exactly one racing request proceeds; the loser gets a 409. The claim is released back to the prior state on every post-claim failure path so a failed attempt never leaves the row stuck in the transient pending state. Also on this money path: - reject an implausibly large purchase count instead of silently truncating it into int32 (gosec G115) so the wrong number of RIs can never be listed; - add listing-state constants matching the AWS ListingStatus enum and use them in place of scattered string literals; - fix US-locale spellings flagged by misspell in comments and log/error text. Adds regression tests reproducing the concurrent-create race (claim loses -> 409, no AWS call) and the claim-error path, plus release-on-failure assertions. Closes #292 CR concurrent-create finding. * fix(marketplace): resolve 3 blocking defects in #808 RI sell flow Defect 1 -- migration number collision: Renumber marketplace migration from 000068 (already deployed, skipped by golang-migrate on prod) to 000084 (next free after main's 000083). Verified: main tops at 000083; #1277 uses 000082; no open PR holds 000084. All "000068" references in SQL comments and types.go updated to 000084. Defect 2 -- offering_class never written: CUDly's EC2 client hardwires OfferingClassTypeConvertible; savePurchaseHistory never stamped the field, so every row landed with offering_class=NULL and the Sell button never rendered. Fix: (a) stamp offering_class='convertible' in savePurchaseHistory for all AWS EC2 purchases so future CUDly-bought rows are correctly classified at write time; (b) add FetchOfferingClass to the marketplaceEC2Client interface and implement it via DescribeReservedInstances so the marketplace-list handler can lazily populate offering_class for pre-migration rows and for externally-created Standard RIs (purchased before CUDly existed); (c) add StampOfferingClass to ConfigStore + PostgresStore + mock to persist the fetched class so subsequent requests skip the extra AWS call; (d) a new populateOfferingClass helper in the handler ties it together -- it runs when offering_class is empty after validateMarketplaceListRequest and before the definitive "standard only" gate. How a 'standard' row comes to exist: an externally-created Standard RI will get its offering_class populated from AWS DescribeReservedInstances on the first POST .../marketplace-list call and the value persisted for future calls. Rows purchased by CUDly are stamped 'convertible' at write time (CUDly only ever buys Convertible EC2 RIs). Defect 3 -- $0 price guardrail gap: resolveMarketplacePriceSchedule accepted Price >= 0, allowing zero-dollar listings. Fix: reject Price <= 0 with a clear error; add a named constant awsMarketplaceMinPriceFloorFraction (5%) and a total-schedule floor check so a schedule that sums to less than 5% of the prorated residual value is also rejected with an explicit message. Extract floor check into checkSuppliedScheduleFloor and the class-check predicate into isKnownNonStandardOfferingClass to keep all touched functions below gocyclo 10. Regression tests: - TestMigration084_MarketplaceColumns: integration test proves the three new columns exist and round-trip after migrating a fresh DB through 000084. - TestMarketplaceList_EmptyOfferingClassFetchedStandard: proves an externally-created Standard RI (offering_class="") is listable after the lazy-populate path fetches and stamps 'standard' from AWS. - TestMarketplaceList_EmptyOfferingClassFetchedConvertible: proves the gate still rejects when AWS reports 'convertible' even if DB had no class. - TestResolveMarketplacePriceSchedule_ZeroPriceRejected: Price=0 rejected. - TestResolveMarketplacePriceSchedule_BelowFloorRejected: sub-floor rejected. * fix(marketplace): renumber migration 000084 -> 000085 to avoid #1277 collision PR #1277 (cancelled->canceled rename) was concurrently renumbered to 000084 and merges before #808. Once it lands, main will hold 000084, so #808's 000084 would collide on the merge ref (pre-commit --all-files migration check) and give golang-migrate two version-84 migrations. Since #1277 takes the lower number and merges first, #808 moves to 000085. - git mv the up/down/test migration files 000084_* -> 000085_* - update the "-- Migration" header in the up.sql and "-- Revert migration" in the down.sql to 000085 - update every 000084 reference in comments: config/types.go (x3), config/interfaces.go, config/store_postgres.go, api/handler_marketplace.go (x2), providers/aws/services/ec2/client.go - rename TestMigration084_MarketplaceColumns -> TestMigration085_* and its fixture identifiers (ri-085-test, ril-085-test) Also fix the integration-test fixture surfaced by running it against a real testcontainer PG: plan_id is a UUID FK, so the literal 'plan-085' failed with SQLSTATE 22P02. The INSERT now lists only NOT-NULL-without-default columns plus the three new marketplace columns and omits plan_id/plan_name/ramp_step/ source (nullable or defaulted), so the test needs no purchase_plans fixture. Verified: migrations apply cleanly through version 85 and the three columns round-trip. * fix(marketplace): correct RI listing price-floor math and reachability Round-3 money-path review fixes for the Sell-on-Marketplace flow (#808). Price floor (was arithmetically wrong): - Enforce the floor PER TIER on the one-time sale Price. AWS PriceScheduleSpecification.Price is the lump-sum a buyer pays when TermMonths remain, not a per-month rate; the old code compared Price*TermMonths against the floor, over-crediting a cheap schedule up to ~term-fold so a $5 tier passed a $60 floor on a $1,200 residual. - Reject any tier whose TermMonths exceeds the RI's remaining months (was unvalidated and arbitrarily inflatable). - Per-instance basis: purchase_history.UpfrontCost is the row total for all Count instances, but Marketplace prices are per instance, so divide the residual by Count in both the floor and the default schedule. - Upfront-only residual: drop recurring (monthly) cost from the residual basis. In the RI Marketplace the buyer assumes recurring charges after transfer, so the seller recovers only the upfront remainder; including recurring overpriced partial-upfront RIs and made no-upfront RIs unpriceable. - Extract marketplaceResidualPerUnit as the shared per-unit basis so the default schedule and the floor cannot drift. Reachability: render the Sell button for completed AWS EC2 rows whose offering_class is empty (unknown), not just "standard". CUDly stamps "convertible" on its own EC2 purchases and externally-created Standard RIs arrive with an empty class until the backend lazily populates it; gating the UI on "standard" alone made the feature unreachable end to end. Provider and service are now checked so unknown-class non-EC2 rows never show the button. Nits: - Use SDK enum constants string(ec2types.OfferingClassType{Standard,Convertible}) instead of raw "standard"/"convertible" literals in the offering-class gate, the isKnownNonStandardOfferingClass predicate, and the purchase-history stamp. - Route populateOfferingClass errors through mapAWSMarketplaceError so an AWS client fault (e.g. InvalidReservedInstancesID.NotFound) surfaces as a 400, not a generic 500. Migration: renumber 000085 -> 000087 to stay above #1422's 000086 (main tops at pre-84; #1422 takes 000086, so this PR takes 000087). Regression tests (fail-before / pass-after verified): - TestResolveMarketplacePriceSchedule_BelowFloorRejected: a {12mo, $5} tier on a $1,200 per-unit residual (Count=1) is now rejected; passed under the old Price*TermMonths floor. - TestResolveMarketplacePriceSchedule_PerUnitFloor: on a $1,200 row-total with Count=3 the per-unit floor is $20, so $19 is rejected and $21 accepted. - TestResolveMarketplacePriceSchedule_TermExceedsRemainingRejected: a tier term beyond the remaining months is rejected. - history-marketplace-sell-button.test.ts: the Sell button renders for a completed AWS EC2 row with an empty offering_class and stays hidden for convertible, non-EC2, non-AWS, active-listing, and anonymous cases. - TestMigration087_MarketplaceColumns: fresh DB migrates cleanly through 000087 and the three columns round-trip.
Summary
Implements in-app purchase revocation with two complementary layers:
completed, the existing AzureCalculateRefund+ReturnSDK call still applies. AWS/GCP return 422 here (no public cancel API).Closes #290.
The Gmail-style pre-fire delay (primary path)
PurchaseDelayHours[0, 168]Flow
PurchaseDelayHours = 0preserves the legacy immediate-execute behavior — opt-in for users who don't want any delay.WHERE status='scheduled' AND scheduled_execution_at <= NOW(). Uses an owner-token compare-and-clear pattern so two overlapping ticks can't fire the same row twice.Why this is the right shape
The original #804 design called the provider cancel API after the SDK purchase had already settled. That works on Azure (7-day refund window) but not on AWS / GCP, which have no public cancel API. Deferring our own SDK call sidesteps the asymmetry entirely — for the common case ("I just clicked approve and changed my mind"), no provider cooperation is needed.
Provider-cancel API fallback (the original #804 design)
Still wired up for rows that pass
scheduled_execution_atwithout being revoked. Useful when the user wants delay=0, or returns a week later.CalculateRefund+ReturnviaarmreservationsSDK, 7-day window. Button shown only for Azure rows within the window.Schema (migration 000065)
ALTER TABLE purchase_history ADD COLUMN IF NOT EXISTS(idempotent):revocation_window_closes_at TIMESTAMPTZ— set at write-path per provider policy (pre-fire delay) OR per provider's published window (fallback).scheduled_execution_at TIMESTAMPTZ— when the deferred SDK call should fire (pre-fire delay only).revoked_at TIMESTAMPTZ,revoked_via VARCHAR(32)— stamped on revoke.support_case_id TEXT— reserved for Phase 2 AWS Support case (feat(email): post-execution notification email with one-click revocation link valid for the AWS cancel window #291).CHECKconstraints enforce audit consistency:revoked_via ∈ {known values, …}or NULL.support_case_idnon-null only whenrevoked_via = 'support-case'.revoked_atandrevoked_viaare jointly null or jointly non-null.Backend
POST /api/purchases/{id}/revokeroute atAuthUserlevel.revoke-own:purchases/revoke-any:purchases.revoke-owngranted by default to all users (mirrorsapprove-own). Ownership uses account-access (GetAllowedAccountsAPI) because history rows pre-datecreated_by_user_id.revoked_at IS NULLguard inMarkPurchaseRevoked. Already-revoked returnsstatus: already_revokedwithout re-firing.internal/scheduler/scheduler.gofor scheduled-purchase fire. Owner-token CAS protects against overlapping ticks.Frontend
canRevokeCompletedRow: provider-agnostic forscheduledstatus (pre-fire window); Azure-only forcompletedstatus (provider window). Both gated on session + not yet revoked.escapeHtmlonpurchase_id.api.revokePurchase()+ success toast + history refresh.scheduled(e.g. "Scheduled to execute in 47h — revoke link sent to email").Tests
scheduled_execution_at <= nowfires SDK, transitions to completed.parseAzureReservationIDstable tests,authorizeSessionRevokeRBAC matrix.Test plan
go test ./...— all packages greennpm testinfrontend/— all suites greenPurchaseDelayHours = 1h, verify email arrives immediately, status isscheduled. Click revoke from email → status flips tocancelled, no SDK call.PurchaseDelayHours = 0, verify legacy immediate-execute behavior unchanged.scheduled_execution_at; verify scheduler fires SDK, status →completed, confirmation email arrives without revoke link.Related
support:CreateCasedrafter for post-window AWS revocation): tracked separately at feat(email): post-execution notification email with one-click revocation link valid for the AWS cancel window #291.Summary by CodeRabbit