Repository navigation
feat(marketplace): sell/cancel Standard RIs on AWS Marketplace (closes #292) - #808
Conversation
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 57 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (22)
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds AWS RI Marketplace listing and cancellation support for Standard RI history rows. It updates permissions, persistence, EC2 client calls, API routes and handlers, and the History UI to create and cancel listings. ChangesAWS RI Marketplace Listing Implementation
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant HistoryUI
participant FrontendAPI
participant Backend
participant EC2
participant Store
User->>HistoryUI: Click Sell on Marketplace
HistoryUI->>FrontendAPI: createMarketplaceListing(purchaseId, priceSchedule)
FrontendAPI->>Backend: POST /api/purchases/{id}/marketplace-list
Backend->>Store: GetPurchaseHistoryByPurchaseID
Backend->>EC2: CreateReservedInstancesListing
Backend->>Store: UpdatePurchaseHistoryListing
Backend-->>FrontendAPI: listing response
FrontendAPI-->>HistoryUI: reload history
User->>HistoryUI: Click Cancel listing
HistoryUI->>FrontendAPI: cancelMarketplaceListing(purchaseId)
FrontendAPI->>Backend: POST /api/purchases/{id}/marketplace-cancel
Backend->>EC2: CancelReservedInstancesListing
Backend->>Store: UpdatePurchaseHistoryListing
Backend-->>FrontendAPI: cancellation response
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/src/history.ts (1)
610-624:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon't let retry-lineage markup suppress marketplace actions.
Line 610 returns as soon as lineage exists, so a completed Standard RI with
retry_attempt_n > 0never reaches Lines 617-623. That hides Sell/Cancel for successful retry descendants, even though they're still eligible completed purchases.Suggested direction
- if (lineage.length > 0) { - return lineage.join(' '); - } - - // Completed Standard RI rows: offer Sell on Marketplace (issue `#292`). - // The button is placed here (after lineage links) so it does not - // interfere with the Retry / Approve / Cancel affordances above. - if (p.purchase_id) { - if (canCancelMarketplaceListing(p)) { - return `<button type="button" class="btn-link history-marketplace-cancel-btn" data-marketplace-cancel-id="${escapeHtml(p.purchase_id)}">Cancel listing ${escapeHtml(p.listing_id || '')}</button>`; - } - if (canSellOnMarketplace(p)) { - return `<button type="button" class="btn-link history-marketplace-sell-btn" data-marketplace-sell-id="${escapeHtml(p.purchase_id)}">Sell on Marketplace</button>`; - } - } + const trailingActions: string[] = []; + if (p.purchase_id) { + if (canCancelMarketplaceListing(p)) { + trailingActions.push( + `<button type="button" class="btn-link history-marketplace-cancel-btn" data-marketplace-cancel-id="${escapeHtml(p.purchase_id)}">Cancel listing ${escapeHtml(p.listing_id || '')}</button>`, + ); + } else if (canSellOnMarketplace(p)) { + trailingActions.push( + `<button type="button" class="btn-link history-marketplace-sell-btn" data-marketplace-sell-id="${escapeHtml(p.purchase_id)}">Sell on Marketplace</button>`, + ); + } + } + + if (lineage.length > 0 || trailingActions.length > 0) { + return [...lineage, ...trailingActions].join(' '); + }🤖 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/history.ts` around lines 610 - 624, The early return when lineage.length > 0 prevents the marketplace buttons from being rendered for completed retry descendants; update the logic in the function handling the history row so that when lineage exists you append (or merge) the marketplace button markup instead of returning immediately — locate the lineage handling and the marketplace block using lineage, p.purchase_id, canCancelMarketplaceListing, canSellOnMarketplace and escapeHtml; either move the marketplace block above the return or build a combined string (lineage.join(' ') + marketplaceMarkup) so Sell on Marketplace / Cancel listing buttons are included for eligible purchases.
🤖 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 `@frontend/src/history.ts`:
- Around line 914-943: The flow skips the required pricing modal: after the
initial confirmDialog in the '.history-marketplace-sell-btn' click handler, open
the RI pricing/pricing-schedule modal (e.g., call a new or existing
showMarketplacePricingModal / showPricingModal function) to display the RI
summary, default per-month price schedule and 12% fee breakdown and allow the
user to adjust/select schedule types (use the schedule types exposed by
frontend/src/api/purchases.ts via api.getPricingSchedules or similar). Only if
the user confirms the pricing modal proceed to call
api.createMarketplaceListing(id); preserve the existing UI state handling
(disable/enable sameRowActions(btn) and call loadHistory() on success) and
handle errors exactly as existing catch blocks do.
- Around line 474-496: canSellOnMarketplace currently omits the required
remaining-term check; update the function (canSellOnMarketplace(p:
HistoryPurchase)) to return false unless the purchase has at least one month
remaining before maturity by inspecting the appropriate remaining-term field on
p (e.g., p.remaining_term, p.remaining_months, or p.termRemaining depending on
the model) and parsing/coercing string values to numbers if needed; add this
gate alongside the existing status/offering_class/listing_state checks so
matured Standard RIs (remaining < 1) do not show the Sell button.
In `@internal/api/handler_marketplace.go`:
- Line 147: The code currently maps every AWS marketplace error to HTTP 502 via
NewClientError("AWS marketplace listing failed: "+err.Error()) which incorrectly
classifies client-side validation/precondition failures; update the handlers in
internal/api/handler_marketplace.go to inspect the returned AWS error (check for
awserr.Error or smithy.APIError / error with Code() and/or HTTPStatusCode()
methods) and map its actual HTTP-level or SDK error code to an appropriate
status (e.g., 4xx for validation/precondition/auth errors, 409 for conflict, 404
for not found, 401/403 for auth/perm, otherwise 502 for server-side), then call
NewClientError with that mapped status and the error message; apply the same
change to the other occurrence that currently returns 502 so client-correctable
errors are propagated with the correct 4xx status.
- Around line 151-156: When UpdatePurchaseHistoryListing(...) fails after AWS
listing creation (the block using purchaseID, result.ListingID, result.State),
do not silently return success; instead treat it as an error: attempt a
compensating rollback of the created listing (call the appropriate marketplace
delete/cleanup method for result.ListingID), log both the DB error and the
rollback outcome, and return an internal error to the API caller; apply the same
change for the other occurrence referenced (the block around lines 207-209) so
DB persistence failures never yield a successful response while external state
exists.
- Around line 117-118: The default listing price uses row.Term as the remaining
months, which overprices older reservations; instead compute remainingMonths as
the actual remaining term (e.g., row.RemainingTerm or row.Term - row.UsedMonths,
or derive from start/end dates and now) and pass that into
resolveMarketplacePriceSchedule when body.PriceSchedule is empty; update the
code around the schedule assignment (the call to resolveMarketplacePriceSchedule
and the local variable schedule) to use this computed remainingMonths and apply
the same fix to the other occurrence referenced (lines ~299-310).
In
`@internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql`:
- Around line 15-18: The migration for table purchase_history adds
offering_class, listing_id, and listing_state but omits required marketplace
columns needed for lifecycle persistence; update the ALTER TABLE statement in
the migration to also ADD COLUMN IF NOT EXISTS listed_at TIMESTAMP WITH TIME
ZONE, listing_price_schedule JSONB (or appropriate type),
listing_proceeds_received NUMERIC (or appropriate money type), and
listing_fee_paid NUMERIC so the poller/sale path (references: table
purchase_history and columns offering_class, listing_id, listing_state) can
persist listing timestamps, price schedules, proceeds, and fees; ensure
nullability and default behavior match existing schema conventions.
In `@providers/aws/services/ec2/client.go`:
- Around line 970-979: Ensure the code never returns an empty ListingID: when
building MarketplaceListingResult (using variables listingID and
listing.ReservedInstancesListingId and listing.Status) validate that
aws.ToString(listing.ReservedInstancesListingId) yields a non-empty string and,
if empty, return a clear error instead of MarketplaceListingResult{ListingID:
"", ...}; apply the same validation & error-return logic in the other similar
blocks (the code paths referenced around the other two snippets) so downstream
handlers never persist an empty ListingID.
---
Outside diff comments:
In `@frontend/src/history.ts`:
- Around line 610-624: The early return when lineage.length > 0 prevents the
marketplace buttons from being rendered for completed retry descendants; update
the logic in the function handling the history row so that when lineage exists
you append (or merge) the marketplace button markup instead of returning
immediately — locate the lineage handling and the marketplace block using
lineage, p.purchase_id, canCancelMarketplaceListing, canSellOnMarketplace and
escapeHtml; either move the marketplace block above the return or build a
combined string (lineage.join(' ') + marketplaceMarkup) so Sell on Marketplace /
Cancel listing buttons are included for eligible purchases.
🪄 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: b216fad4-4a77-4b3b-be24-d77f8f3b76d4
📒 Files selected for processing (24)
frontend/src/api/index.tsfrontend/src/api/purchases.tsfrontend/src/history.tsfrontend/src/types.tsinternal/analytics/collector_test.gointernal/api/handler.gointernal/api/handler_marketplace.gointernal/api/mocks_test.gointernal/api/router.gointernal/auth/service_group_test.gointernal/auth/service_test.gointernal/auth/types.gointernal/auth/types_test.gointernal/config/interfaces.gointernal/config/store_postgres.gointernal/config/store_postgres_pgxmock_test.gointernal/config/types.gointernal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sqlinternal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sqlinternal/mocks/stores.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/test_helpers_test.goproviders/aws/services/ec2/client.go
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Fixed the compile/type issues (missing MockEC2Client methods for the extended EC2API interface), addressed the 7 CR findings (6 were already in prior commits and verified against HEAD; the Describe/Cancel empty-listing-ID fallback is now applied), and resolved the pre-commit failures (gocyclo on marketplaceList via extracting validateMarketplaceListRequest, and the stale permissions.generated.ts codegen) in 03ab7f5. @coderabbitai review |
|
(ᴗ˳ᴗ) ✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
All 8 CodeRabbit findings from the 2026-05-28 review are resolved in commits a3cfc89 to 03ab7f5. Verification summary: frontend/src/history.ts
internal/api/handler_marketplace.go
internal/database/postgres/migrations/000060_...
providers/aws/services/ec2/client.go
Tests: @coderabbitai review |
|
(ᴗ˳ᴗ) ✅ Actions performedReview triggered.
|
|
All 7 CR findings from the 2026-05-28 review (plus the outside-diff lineage composability note) are resolved and confirmed in the 2026-06-01T19:33 incremental review. No new findings were raised in that review pass. Verification summary: 7 inline threads - all confirmed LGTM by CR at 2026-06-01T19:33:
Outside-diff finding (lineage composability - history.ts:610-624): already fixed in the same commit range. trailingActions array is built before lineage join at history.ts:624-635 so retry descendants with eligible listings get Sell/Cancel buttons. Build and tests pass: go build ./... clean, go test ./internal/api/... 1355 passed, go test ./providers/aws/services/ec2/... 39 passed. @coderabbitai review |
|
(ᴗ˳ᴗ) ✅ Actions performedReview triggered.
|
|
Rebased on Conflict resolution:
Verification:
All previous CR findings from the 2026-05-28 pass remain addressed (no logic changes to marketplace handlers, history.ts, ec2 client, or migration). The outside-diff-range note ( @coderabbitai review |
|
The clean build + 3292 tests passing + frontend Jest green across all touched packages is a solid confidence signal. Triggering a fresh incremental review now to cover the rebase commits. (ᴗ˳ᴗ) 🧠 Learnings used✅ Actions performedReview triggered.
|
|
Rebased The prior round's 7 CR findings were addressed in the branch history:
Note: @coderabbitai review |
|
(ᴗ˳ᴗ) 🧠 Learnings used✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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_marketplace.go`:
- Around line 401-412: The default price schedule is using the full upfrontCost
instead of the prorated remaining upfront; update
resolveMarketplacePriceSchedule to accept the original term (e.g., add a
parameter originalTerm int64 or float64) and compute upfrontRemaining :=
upfrontCost * (float64(remainingMonths) / float64(originalTerm)), then set
totalValue := upfrontRemaining + (monthlyCost * float64(remainingMonths)) and
continue with listPrice := totalValue * 0.95 and clamping to >=0; also update
the call site in marketplaceList to pass row.Term into
resolveMarketplacePriceSchedule so the function has the original term available.
🪄 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: a47024a4-0465-406d-b1b1-370d84820e04
📒 Files selected for processing (25)
frontend/src/api/index.tsfrontend/src/api/purchases.tsfrontend/src/history.tsfrontend/src/permissions.generated.tsfrontend/src/types.tsinternal/analytics/collector_test.gointernal/api/handler.gointernal/api/handler_marketplace.gointernal/api/mocks_test.gointernal/api/router.gointernal/auth/service_group_test.gointernal/auth/types.gointernal/auth/types_test.gointernal/config/interfaces.gointernal/config/store_postgres.gointernal/config/store_postgres_pgxmock_test.gointernal/config/types.gointernal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sqlinternal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sqlinternal/mocks/stores.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/test_helpers_test.goproviders/aws/services/ec2/client.goproviders/aws/services/ec2/client_test.go
💤 Files with no reviewable changes (15)
- internal/auth/types_test.go
- internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql
- providers/aws/services/ec2/client_test.go
- internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql
- internal/server/test_helpers_test.go
- internal/config/interfaces.go
- internal/scheduler/scheduler_test.go
- internal/purchase/mocks_test.go
- internal/auth/service_group_test.go
- internal/auth/types.go
- internal/config/store_postgres_pgxmock_test.go
- internal/mocks/stores.go
- internal/config/types.go
- providers/aws/services/ec2/client.go
- internal/config/store_postgres.go
✅ Files skipped from review due to trivial changes (1)
- frontend/src/permissions.generated.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- internal/api/mocks_test.go
- internal/api/router.go
- internal/api/handler.go
- frontend/src/types.ts
- frontend/src/api/purchases.ts
- frontend/src/history.ts
- frontend/src/api/index.ts
|
Pushed a follow-up fix: extracted @coderabbitai review |
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.
…lt 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.
…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.
000060 is taken by 000060_cleanup_universal_plans on base.
…se 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.
…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).
… 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).
…eline - 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.
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.
…t 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.
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.
…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.
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.
Expand-contract rename of all British-spelled 'cancelled'/'cancellable' variants to US-spelled 'canceled'/'cancelable' across the codebase. - Add migration 000089: adds canceled_by column alongside cancelled_by, widens CHECK constraints to accept both spellings, COALESCE reads both during the expand window (prev version 088) - Rename field CancelledBy->CanceledBy in PurchaseExecution, update json tag to canceled_by; add IsImmediatelyCancelable() predicate - Rename SetCancelledBy->SetCanceledBy in StoreInterface + all implementations and mocks - Update handler_purchases, handler_purchases_revoke and all tests to use US spellings; replace em-dashes with double hyphens in comments - Patch frontend history.ts, riexchange.ts and OpenAPI spec to use 'canceled' status string Rebased onto main (473f69b); migration renumbered from 000082 to 000089 to land after in-flight #808 (000087) and #1428 (000088).
|
@coderabbitai review |
✅ Action performedReview finished.
|
Expand-contract rename of all British-spelled 'cancelled'/'cancellable' variants to US-spelled 'canceled'/'cancelable' across the codebase. - Add migration 000089: adds canceled_by column alongside cancelled_by, widens CHECK constraints to accept both spellings, COALESCE reads both during the expand window (prev version 088) - Rename field CancelledBy->CanceledBy in PurchaseExecution, update json tag to canceled_by; add IsImmediatelyCancelable() predicate - Rename SetCancelledBy->SetCanceledBy in StoreInterface + all implementations and mocks - Update handler_purchases, handler_purchases_revoke and all tests to use US spellings; replace em-dashes with double hyphens in comments - Patch frontend history.ts, riexchange.ts and OpenAPI spec to use 'canceled' status string Rebased onto main (473f69b); migration renumbered from 000082 to 000089 to land after in-flight #808 (000087) and #1428 (000088).
… spelling) (#1277) * fix(db): rename cancelled->canceled (expand-contract, migration 000089) Expand-contract rename of all British-spelled 'cancelled'/'cancellable' variants to US-spelled 'canceled'/'cancelable' across the codebase. - Add migration 000089: adds canceled_by column alongside cancelled_by, widens CHECK constraints to accept both spellings, COALESCE reads both during the expand window (prev version 088) - Rename field CancelledBy->CanceledBy in PurchaseExecution, update json tag to canceled_by; add IsImmediatelyCancelable() predicate - Rename SetCancelledBy->SetCanceledBy in StoreInterface + all implementations and mocks - Update handler_purchases, handler_purchases_revoke and all tests to use US spellings; replace em-dashes with double hyphens in comments - Patch frontend history.ts, riexchange.ts and OpenAPI spec to use 'canceled' status string Rebased onto main (473f69b); migration renumbered from 000082 to 000089 to land after in-flight #808 (000087) and #1428 (000088). * fix(db): restore correct cancel error paths lost in rebase conflict The rebase of 000089 onto current main incorrectly resolved two conflicts in handler_purchases.go: 1. cancelOrRecoverExecution: reverted fmt.Errorf (router->500) back to NewClientError(409,...), misclassifying retriable backend faults as caller faults (feedback_http_status_classification). 2. cancelPurchaseViaSession: took main's broad !IsCancelable() guard instead of the PR's narrower guardImmediatelyCancelable, allowing "scheduled" executions through the pending/notified-only CAS path and producing a misleading "concurrent operation" 409 instead of the clear "use the revoke endpoint" message. Fix: restore fmt.Errorf for the backend-failure branch; add guardCancelableViaSession that explicitly routes "scheduled" to the revoke endpoint before falling through to IsCancelable; rename residual cancelledBy -> canceledBy; remove extra blank line in types.go. Regression tests: TestHandler_deletePlannedPurchase_BackendErrorReturns5xx and TestHandler_cancelPurchase_Session_ScheduledRoutedToRevoke now pass.
… and consent modal (follow-up to #808) Mirror of the backend years-as-months fix on the frontend. purchase_history.term is stored in years (1 or 3), but frontend/src/history.ts consumed it as months in two places on the Sell-on-Marketplace path: - canSellOnMarketplace (~line 663): computed remainingMonths = term - elapsedMonths with term in years, so a 3-year RI was treated as 3 months and the Sell button vanished after ~3 months of elapsed time. - Consent/pricing modal (~line 1210): computed the residual with term in years, so the resale price summary shown to the user was ~1/3 of the real value (e.g. a 3yr RI 6 months in showed ~$0/underpriced instead of ~$2,850 on $3,600 upfront). Fix: convert term years->months at the boundary (termYears * 12) before computing remainingMonths/residual in both spots, and guard term <= 0. Correct the existing makeRow() test fixture from term:36 (36 years, nonsensical, masked the bug) to term:3. Add a modal-residual regression test asserting a 3yr RI ~6 months in shows ~30 months remaining and a ~$2,850 list price; it fails pre-fix (the gate hides the button) and passes post-fix. Parallels the backend TestMarketplaceList_TermYearsConvertedToMonths.
… (follow-up to #808) (#1447) * fix(api/marketplace): convert RI term years->months in resale pricing (follow-up to #808) purchase_history.term is stored in years (1 or 3), confirmed by migration 000007 ("valid terms are 0, 1, or 3 (years)") and execution.go formatting it as "%dyr". The marketplaceList handler was passing row.Term unchanged to computeRemainingMonths (param: termMonths int) and resolveMarketplacePriceSchedule (param: originalTerm, documented as months), silently treating years as months. Impact for a 3-year RI sold 6 months in: pre-fix: remainingMonths = max(1, 3-6) = 1; default price ~= $1,140 (3600 * 1/3 * 0.95) post-fix: remainingMonths = 30; default price ~= $2,850 (3600 * 30/36 * 0.95) A caller-supplied {term_months: 30} schedule was also rejected pre-fix (30 > 1 remaining), preventing sellers from specifying the correct term. Fix: guard row.Term <= 0 (error, not silent fallback), then multiply by 12 at the boundary where years enter the pricing math and pass termMonths to both call sites. Update standardRow() in tests from Term:12 (nonsensical 12 years, accidentally masked the bug) to Term:3. Add TestComputeRemainingMonths unit test and TestMarketplaceList_TermYearsConvertedToMonths end-to-end regression test that fails pre-fix and passes post-fix. * fix(frontend/marketplace): convert RI term years->months in Sell gate and consent modal (follow-up to #808) Mirror of the backend years-as-months fix on the frontend. purchase_history.term is stored in years (1 or 3), but frontend/src/history.ts consumed it as months in two places on the Sell-on-Marketplace path: - canSellOnMarketplace (~line 663): computed remainingMonths = term - elapsedMonths with term in years, so a 3-year RI was treated as 3 months and the Sell button vanished after ~3 months of elapsed time. - Consent/pricing modal (~line 1210): computed the residual with term in years, so the resale price summary shown to the user was ~1/3 of the real value (e.g. a 3yr RI 6 months in showed ~$0/underpriced instead of ~$2,850 on $3,600 upfront). Fix: convert term years->months at the boundary (termYears * 12) before computing remainingMonths/residual in both spots, and guard term <= 0. Correct the existing makeRow() test fixture from term:36 (36 years, nonsensical, masked the bug) to term:3. Add a modal-residual regression test asserting a 3yr RI ~6 months in shows ~30 months remaining and a ~$2,850 list price; it fails pre-fix (the gate hides the button) and passes post-fix. Parallels the backend TestMarketplaceList_TermYearsConvertedToMonths.
…ECT (#1472) GetActivePurchaseHistory SELECTed 21 columns but shares scanPurchaseHistoryRow, which scans 24 destinations (offering_class, listing_id, listing_state were added by #808). pgx/v5 Rows.Scan requires field count == destination count, so every real-Postgres call to this query failed with "number of field descriptions must equal number of destinations, got 21 and 24". Impact (all broken on real Postgres, silently in some paths): - dashboard commitment KPIs (fetchCommitmentPurchases swallows the error and renders 0 active commitments / $0 committed monthly / $0 YTD), - GET /api/inventory/commitments and /api/inventory/coverage (return errors), - the analytics collector's Collect (fails every run). #808 (marketplace) added the three columns to the scanner and to GetPurchaseHistory / GetAllPurchaseHistory / GetPurchaseHistoryFiltered but missed GetActivePurchaseHistory. This is the same class of defect #1221 fixed for the 17-vs-21 case. Fix: append offering_class, listing_id, listing_state so the column list is identical to the other purchase-history queries and matches the scanner. Update the pgxmock regex to pin the corrected column list so a future drop of these columns fails the test. Note: pgxmock cannot reproduce the field-count mismatch (it fabricates result columns), so the regression is only guarded via the SQL-text regex here; a shared "emitted SELECT column list == purchaseHistoryCols" assertion across all purchase-history queries is a worthwhile follow-up. Found by the adversarial (Fable) review sweep of recently-merged PRs.
…1493) resolveMarketplacePriceSchedule's default-schedule branch silently clamped a negative computed list price to 0 instead of rejecting it. A no-upfront RI (upfrontCost <= 0) or one with an unknown/elapsed term (originalTerm <= 0) makes marketplaceResidualPerUnit return 0, so the RI would be listed on AWS Marketplace for free instead of erroring out like the supplied-schedule branch already does for a non-positive price. The backend now returns an explicit error asking the caller to supply a price_schedule. The sell-consent-modal preview in frontend/src/history.ts had a parallel bug: it computed the preview price with a different formula than the backend (including recurring monthly cost, which the backend deliberately excludes, and not dividing by instance count), so it could show a nonzero preview price for exactly the case the backend now rejects, and diverge from the real listing price for multi-count RIs. The preview now mirrors marketplaceResidualPerUnit and resolveMarketplacePriceSchedule's default branch exactly, and shows "Default list price: unavailable (no upfront cost or unknown term)" instead of a fabricated or zero price when no default can be computed. Adds TestResolveMarketplacePriceSchedule_ZeroDefaultPriceRejected (backend) and two regression tests in history-marketplace-sell-button.test.ts (frontend): one pinning the per-unit formula against the old recurring-inclusive row-total formula for a multi-count RI, one asserting the no-upfront case shows "unavailable" and never "$0". Follow-up to #808, found during an adversarial review sweep.
…290) (#804) * feat(purchases): in-app revocation within free-cancel window (closes #290) POST /api/purchases/{purchaseId}/revoke: Azure returns via armreservations (CalculateRefund + Return two-step), 7-day window. AWS/GCP return 422 and the History UI hides the button for those providers. - DB: migration 000057 adds revocation_window_closes_at, revoked_at, revoked_via, support_case_id columns to purchase_history - RBAC: revoke-own / revoke-any actions, revoke-own granted to all users by default; ownership is via account-access (history rows pre-date created_by_user_id) - Backend: fail-closed nil-auth guard, idempotency via revoked_at IS NULL, Azure CalculateRefund->Return with test-injectable client interfaces - Frontend: canRevokeCompletedRow gate (azure + window open + not yet revoked), Revoke button in History action cell, confirm dialog + toast - All existing mock stores updated with GetPurchaseHistoryByPurchaseID and MarkPurchaseRevoked; auth permission-count tests updated to 12 * fix(ci): extract provider dispatch and account check to reduce revoke complexity; regenerate permissions on PR #804 * fix(api/purchases): align revoke admin check with group-only authz (#907) Rebase onto feat/multicloud-web-frontend brought in #907 (group-membership- only authorization, no role field on Session). The revoke handler still gated admin via `session.Role == "admin"`, which no longer compiles since api.Session has no Role field. Replace with the same two-track pattern the sibling authorizeSessionCancel / authorizeSessionApprove already use: - Stateless admin API key short-circuits via `session.UserID == apiKeyAdminUserID` (no DB row exists to resolve permissions from). - Group-based admins fall through to HasPermissionAPI; the {admin, *} wildcard in DefaultAdminPermissions matches revoke-any:purchases there. Drop the dead `Role` field from the revoke test sessions; the admin test now pins the apiKeyAdminUserID short-circuit, and the existing RevokeAny test already covers the group-admin path via HasPermissionAPI. Also fold in the trailing pre-commit fixes that were red on the previous push: - gofmt: realign struct-field padding in TestRevokePurchase_AzureReturnClientError. - go mod tidy: promote armreservations from indirect to direct (the revoke handler imports it directly). Free-cancel window enforcement (AzureRevocationWindowDays + windowClosesAt check) and revoke-call idempotency (early return when record.RevokedAt is already set) are unchanged. Refs #290 * fix(api/purchases/revoke): fail-closed on nil account + return error on DB persist failure Two security fixes from CR findings: 1. checkRevokeOwnAccountAccess: return 403 when CloudAccountID is nil/empty instead of allowing the revoke. Without an account association, ownership cannot be verified for revoke-own callers. Add regression test for this fail-closed contract. 2. revokeAzurePurchase: return error when MarkPurchaseRevoked fails after a successful Azure return. The previous log-and-continue left the DB unmarked, breaking idempotency on retries. The error message notes that the refund was submitted so operators can investigate without re-issuing. * fix(purchases/revoke): populate revocation window at write path so the button works The Revoke button was shipped but dead: RevocationWindowClosesAt was never populated when a completed purchase was written, so the frontend gate canRevokeCompletedRow (which bails on a missing revocation_window_closes_at) hid the button on every real row. - Stamp RevocationWindowClosesAt in the real write path (purchase.savePurchaseHistory): Azure = Timestamp + 7 days (the free-cancel window), nil for AWS/GCP (out of Phase-1 scope), via a new shared config.RevocationWindowClosesAtFor helper + config.AzureRevocationWindowDays constant that is now the single source of truth for the window length. - Make the backend window check (revokeAzurePurchase) read RevocationWindowClosesAt as the source of truth, falling back to recomputing from Timestamp only for legacy rows written before the column was populated. - Reject an order-only ARM path (empty reservationID) in callAzureReturn rather than submitting an empty Return to Azure. - Tests: backend asserts savePurchaseHistory stamps the window for Azure and leaves it nil for AWS/GCP; handler asserts the stamped window drives the deny decision and that an empty reservationID is rejected; FE test asserts the Revoke button shows for a completed Azure row with a populated revocation_window_closes_at and is hidden without it (plus closed-window, already-revoked, non-Azure, and anonymous cases). * docs(auth/revoke): correct revoke-own doc to account-scope reality (#950) The ActionRevokeOwn doc comment claimed "Own" means created_by_user_id matches the session user, but checkRevokeOwnAccountAccess actually enforces ACCOUNT scope (GetAllowedAccountsAPI), because purchase_history rows pre-date created_by_user_id and have no reliable per-creator attribution. Fix the doc comments to describe the account-scope behavior as implemented; the authz model itself is unchanged. Whether revoke-own should instead be creator-scoped is a product decision tracked in issue #950, noted inline in both the auth constant doc and the handler check. * feat(purchases/revoke): Gmail-style pre-fire delay unifies revoke across providers Adds status=scheduled to purchase_executions so the approval step defers the cloud SDK call by purchase_delay_hours. A scheduled execution can be cancelled at $0 via the existing revoke endpoint before the scheduler fires. Two-tier revoke path: - status=scheduled: CancelExecutionAtomic (CAS) transitions to cancelled; no cloud API call, returns 200 with explicit "no cost incurred" message. - status=completed (existing): provider SDK call via purchase_history path. revokePurchase now tries GetExecutionByID first; falls through to the existing purchase_history lookup when no scheduled execution is found. authorizeSessionRevokeExecution mirrors authorizeSessionRevoke but uses CreatedByUserID (not CloudAccountID) for revoke-own scoping. FireScheduledDelayedPurchases (purchase.Manager) drives the scheduler tick: queries GetScheduledExecutionsDue, CAS-transitions scheduled->approved, stamps ApprovedBy="scheduler", calls executeAndFinalize. Registered as TaskFireScheduledPurchases in the Lambda task dispatcher. Also folds in three CodeRabbit findings from PR #804 review f9c66c1e: - Remove unused mockAuth in two AzureCalcRefundClientError/ReturnClientError tests - Add idempotent audit CHECK constraints to migration 000065 (revoked_via, support_case_id, revoked_at/revoked_via pair) - Frontend regression test: legacy blank-status Azure rows stay revocable - analytics/collector_test.go: hook-backed GetPurchaseHistoryByPurchaseID and MarkPurchaseRevoked mocks (no more hardcoded no-ops) * refactor(api/config): extract helpers to keep revoke+approve+email+scan under gocyclo limit revokePurchase -> loadAndRevokePurchaseHistory (handler_purchases_revoke.go) approvePurchase -> approveViaToken (handler_purchases.go) sendPurchaseScheduledEmail -> buildScheduledEmailData (handler_purchases.go) scanExecutionRows -> applyNullTimesToExecution (store_postgres.go) Also applies gofmt alignment fix to internal/analytics/collector_test.go. * refactor(test/mocks): consolidate MockConfigStore into shared internal/mocks (#804 cleanup) Three per-package MockConfigStore definitions (internal/api, internal/purchase, internal/scheduler) were duplicating ~450 LOC each every time a new StoreInterface method was added. Replace all three with a type alias pointing at internal/mocks.MockConfigStore. Changes to internal/mocks/stores.go: - Add Fn-override fields imported from the api and purchase local mocks (GetCloudAccountFn, GetPurchasePlanFn, SetPlanAccountsFn, SavePurchaseExecutionFn, GetPlanAccountsFn, and 6 others) so callers that used those fields keep working without changes. - Add isExpected guards to recommendation-cache and RI-utilization-cache methods and to CancelExecutionAtomic so existing tests that call these without explicit On() expectations don't panic (matches the "opt-in" pattern the per-package mocks used via hasRecExpectation / hasExpectation). - Add 8 interface methods that were missing from the shared mock but present in all per-package variants: GetExecutionsByStatuses, GetPlannedExecutions, GetStaleApprovedExecutions, ListStuckExecutions, GetScheduledExecutionsDue, MarkCollectionStarted, ClearCollectionStarted, StampRIExchangeApprovedBy. - Add compile-time check: var _ config.StoreInterface = (*MockConfigStore)(nil). - Promote GetGlobalConfig and GetPurchasePlan to return sensible defaults when no expectation is registered (matches the api-package behaviour these tests relied on). Per-package files reduced to a single type alias line each. The scheduler test also had stray suppression/Tx method stubs added in a later commit; those are removed because the methods already live on the shared mock. Intentionally left local (incompatible shape or semantics): - internal/analytics/collector_test.go: mockConfigStore (lowercase) -- hook-field only pattern with no testify embedding; analytics-specific subset; different name. - internal/server/test_helpers_test.go: mockConfigStoreForHealth -- all-zero-value stubs for health check tests; distinct type name; no testify embedding. - internal/server/handler_ri_exchange_test.go: mockConfigStoreForExchange -- test- specific struct overriding a handful of methods. - internal/server/handler_coverage_test.go: mockConfigStoreForExchange{Complete, Fail,Stale} -- per-scenario stubs with distinct type names. Net LOC: +274 insertions / -1601 deletions (-1327 net across 4 files). * fix(api/purchases/revoke): use status='scheduled' CAS so pre-fire revoke isn't dead The pre-fire delay revoke path called CancelExecutionAtomic, whose SQL guard is `WHERE status IN ('pending','notified')`. A status='scheduled' row never matches, so the CAS returned zero rows on the happy path -- the handler then surfaced 410 "revocation window has closed" even when the window was wide open. The user would pay the cloud charge AND see a "cancelled" attempt in the UI -- the worst possible outcome. The bug was hidden by mocks defaulting CancelExecutionAtomic to (true,"cancelled",nil), so every test in the scheduled-revoke suite was green against the wrong SQL. No pgxmock test exercised the WHERE clause. Fix: introduce CancelScheduledExecutionAtomic with the correct `WHERE status = 'scheduled'` guard and switch the handler to it. The two CAS variants are kept distinct on purpose -- the scheduled-revoke flow surfaces 410 ("scheduler already fired") on race-loss, while the pre-purchase cancel flow surfaces 409 ("not pending"). Sharing one method would conflate the two race outcomes. Regression test TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS pins the call to CancelScheduledExecutionAtomic with an Expect and adds an AssertNotCalled for CancelExecutionAtomic; verified to fail pre-fix (mock expectation unmet, wrong method called) and pass post-fix. Touches: internal/config/{store_postgres,interfaces}.go -- add method + comments internal/api/handler_purchases_revoke{,_test}.go -- switch call site + reg test internal/mocks/stores.go -- mock the new method (default happy path) internal/server/test_helpers_test.go, internal/analytics/collector_test.go -- satisfy StoreInterface * fix(frontend/history): gate Revoke button on revoke-{any,own} permission canRevokeCompletedRow only checked getCurrentUser() truthiness, so the inline Revoke button rendered for every signed-in user regardless of the revoke-any / revoke-own grant. The backend correctly 403s, but the UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete on the same page. The peer predicates (canCancelPendingRow, canApprovePendingRow, canRetryFailedRow) already check canAccess; canRevokeCompletedRow now does too. Verbs match the backend handler one-to-one: - admin or revoke-any:purchases -> always allowed - revoke-own:purchases -> allowed (account-scope enforced server-side) - anything else -> hidden Adds revoke-own / revoke-any to the closed Action union in permissions.ts so a future drift becomes a compile error at the canAccess call site. Adds a regression test that mocks getCurrentUser with an effectivePermissions set lacking revoke-* and asserts the button is hidden; verified to fail without the canAccess gate and pass with it. Also corrects the existing ADMIN_USER fixture to use the real ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was inert because the new canAccess fallback drives off isAdmin() which checks GUID membership. * test(frontend/permissions): update USER_PERMS expected set for revoke-own PR #804 added 'revoke-own:purchases' to USER_PERMS in permissions.generated.ts but missed updating the user-role expected list in __tests__/permissions.test.ts. The test asserts perms.size matches expected.length, so the missing entry surfaced as "Expected 11, received 12" after the addition. This is the same scope as the parent permissions add -- not a separate permission grant, just the test parity update PR #804 should have included alongside the original add. * fix(server): table-drive ParseScheduledEvent + cover scheduled-fire sweep The fire_scheduled_purchases case pushed ParseScheduledEvent's cyclomatic complexity to 11, tripping the gocyclo<=10 pre-commit hook and leaving the PR UNSTABLE. Replace the action switch with a package-level lookup table so the complexity no longer grows with the task list; adding a task type stays a one-line change. Also close the test gap on the Gmail-style pre-fire delay scheduler sweep: FireScheduledDelayedPurchases / fireOneDue had no unit coverage of their result accounting or CAS-race classification (only the dispatch wiring was mocked). Add scheduled_fire_test.go mirroring reaper_test.go: - no-due-rows and list-error paths - CAS lost to a concurrent revoke -> RaceLost, not Errored, and no SDK fire (the safety property that prevents double-charging a revoked purchase) - row-vanished (ErrNotFound) -> RaceLost - hard DB error on the CAS -> Errored (per-row isolation, sweep still succeeds) Each race/error test fails if the classification regresses (verified by flipping fireOneDue's return). Add the fire_scheduled_purchases case to the ParseScheduledEvent table test so the new action is positively asserted. * fix(migrations): renumber 000068 -> 000070 to deconflict (refs #290) PR #808 keeps 000068 and PR #847 takes 000069; bump this branch's purchase_history_revocation migration to 000070 to avoid conflicts on feat/multicloud-web-frontend. * fix(scheduler): wire FireScheduledDelayedPurchases tick (CRITICAL: pre-fire delay branch was non-functional) - Add testify-based FireScheduledDelayedPurchases to scheduler's MockPurchaseManager so it records calls and satisfies mock.AssertExpectations. - Add TestSchedulerManagerInterface_FireScheduledDelayedPurchasesWired: compile-time guard that ManagerInterface exposes the method + call-recording smoke test. - Add TestFireScheduledDelayedPurchases_EndToEnd in scheduled_fire_test.go: skipped placeholder (with documented skip reason + issue ref) for the full provider-stub e2e once #1005 4-eyes lands. - Add TestFireScheduledDelayedPurchases_DelayPathNotSilentNoOp: compile-time guard that FireScheduledDelayedPurchases exists on Manager. The server/handler_test.go "fire_scheduled_purchases success" and "fire_scheduled_purchases propagates error" cases cover the full dispatch chain (ScheduledTaskType -> handleFireScheduledPurchases -> Purchase.FireScheduledDelayedPurchases). * fix(api/purchases): CAS-guard scheduleApprovedExecution to prevent silent revoke loss Replace the blind SavePurchaseExecution write in scheduleApprovedExecution with a two-step CAS pattern: 1. TransitionExecutionStatus(pending|notified -> scheduled) -- atomic CAS. 2. Stamp ScheduledExecutionAt + ApprovedBy on the returned post-CAS row. 3. SavePurchaseExecution to persist the stamps. Before this fix a concurrent Cancel that landed between the approve handler's SELECT and its SavePurchaseExecution would be silently overwritten: the cancelled row would become status="scheduled" and eventually fire the cloud SDK call the user explicitly revoked. The CAS ensures the write succeeds only when the row is still in pending or notified; a concurrent cancel causes ErrExecutionNotInExpectedStatus which surfaces as a clear error to the caller. Tests added: - TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel: injects a concurrent-cancel error and asserts SavePurchaseExecution is never called. - TestHandler_scheduleApprovedExecution_HappyPath: normal flow, asserts ScheduledExecutionAt is stamped on the transitioned row. * feat(purchases/revoke): two-step quote-then-confirm + persist refund amount for audit Addresses adversarial-review Finding #4: - Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke) that calls CalculateRefund and returns the quoted refund amount and currency; no state mutation, used by the frontend confirmation modal. - Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase -> callAzureReturn; on POST /revoke the client sends the amount it consented to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU window between user confirmation and actual Return call. - Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the audit row captures the quoted values even if the actual refund differs later. - Migration 000071 adds the two new nullable columns and a consistency CHECK constraint (currency must be non-empty when amount is present). - New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422, TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds, TestCallAzureReturn_AuditRowPopulatedWithQuote. * fix(purchases/revoke): partial-success reconciliation; never retry a refund that already succeeded Addresses adversarial-review Finding #6: - Migration 000072 adds revocation_in_flight BOOLEAN NOT NULL DEFAULT false to purchase_history plus a partial index on rows where the flag is true. - callAzureReturn flips revocation_in_flight=true via FlipPurchaseRevocationInFlight immediately before the Azure Return API call so the row is visible to the finalize sweep if the subsequent MarkPurchaseRevoked DB write fails. - MarkPurchaseRevoked is retried up to 3 times with 1s/3s/9s backoff after Azure Return succeeds; if all retries fail, the endpoint returns a revokeReconcilePendingResult (HTTP 207 body with code=RECONCILE_PENDING, azure_returned=true) so the frontend shows a non-retryable toast rather than prompting the user to retry a refund that Azure already issued. - loadAndRevokePurchaseHistory detects a row with revocation_in_flight=true and revoked_at=nil and immediately returns 207 RECONCILE_PENDING to prevent any duplicate Azure Return call on retry. - purchase.Manager.FinalizeInFlightRevocations sweeps rows matching GetPurchaseHistoryInFlight and retries MarkPurchaseRevoked with 2s/6s backoff per row; the finalize_revocations scheduled task wires this sweep into the Lambda event handler. - New tests: TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries, TestLoadAndRevokePurchaseHistory_RevocationInFlightReturns207, finalize_revocations success and error dispatch tests. * fix(purchases/revoke): typed Azure error classification (no more substring match on err.Error()) Addresses adversarial-review Finding #7: Replace the string-based isAzureClientError implementation with typed error inspection using errors.As(err, &*azcore.ResponseError). The substring-match approach had two failure modes: - False positives: any error whose .Error() string contains "400" (e.g. a network timeout "timeout after 400ms") would be misclassified as a client error, hiding transient infra problems from the operator. - False negatives: Azure refund-policy errors with HTTP codes not in the literal set (e.g. 403, 405) would be escalated as 500. The typed approach classifies exactly the HTTP status codes Azure uses for policy violations and bad requests (400, 403, 404, 405, 409, 422); all other errors (transport errors, 5xx, plain errors) correctly classify as server-side. Updated TestRevokePurchase_AzureCalcRefundClientError to inject a real *azcore.ResponseError{StatusCode: 400} instead of errors.New("400: ..."). New tests: TestIsAzureClientError_SubstringFalsePositive, TestIsAzureClientError_TypedResponseError (covers 4xx client + 5xx server). * fix(purchases/revoke): 1h safety margin on local window + clean 422 on Azure window-edge rejection Addresses adversarial-review Finding #3: Add azureRefundSafetyMargin = 1h so the in-app revoke button disappears and revoke requests are rejected 1h before Azure's hard 7-day deadline. This eliminates the tail of RefundPolicyViolated failures caused by clock skew between CUDly's clock and Azure's at the window boundary. The safety margin is applied only in the local pre-flight check. The value stored in purchase_history.revocation_window_closes_at remains the unmodified Azure deadline so operators can see the true expiry. Additionally, detect RefundPolicyViolated errors from the Return API via the new isAzureWindowEdgeError helper (typed errors.As on *azcore.ResponseError, checking ErrorCode == "RefundPolicyViolated") and map them to a clean 422 with "window has closed" message rather than the generic "Azure refund rejected" 400 path. This handles the race where our safety-margin check passes but Azure's clock disagrees mid-flight. New tests: - TestRevokePurchase_AzureWithinSafetyMarginRejected: purchase 6d23h30m ago (30min before edge) rejected locally despite Azure deadline not yet passed. - TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed: purchase 6d22h30m ago (90min before edge) accepted. - TestIsAzureWindowEdgeError: table test covering RefundPolicyViolated, other error codes, nil, and plain-error false-positive. - TestCallAzureReturn_RefundPolicyViolatedReturns422WindowEdge: Return API returning RefundPolicyViolated surfaces as 422, not 400. * fix(migrations): allow support-case revoke to record in-flight state (case filed, awaiting AWS) Addresses adversarial-review Finding #5: The original purchase_history_revoked_pair_chk required revoked_at and revoked_via to be set or unset together. This is too strict for the AWS support-case revocation path (issue #291 wave-2): when a case is filed, revoked_via='support-case' is recorded immediately for the audit trail, but revoked_at stays NULL until AWS confirms the refund. The pair check fires as a constraint violation in that in-flight state. Migration 000073 drops the pair check and simultaneously tightens the support-case companion check: Old: CHECK (support_case_id IS NULL OR revoked_via = 'support-case') (prevents support_case_id on non-support-case rows only) New: CHECK (revoked_via != 'support-case' OR support_case_id IS NOT NULL) (requires support_case_id whenever revoked_via = 'support-case') The meaningful invariant (no dangling revoked_at without a known provider path) is preserved by the existing purchase_history_revoked_via_chk which constrains revoked_via to ('direct-api', 'support-case'). * test(purchases/revoke): DST-crossing window math + 4-eyes approval placeholder Addresses adversarial-review Finding #9: TestRevocationWindowClosesAtFor_DSTCrossing: verifies that AddDate(0,0,7) (calendar arithmetic) produces the correct 7-day window across a DST transition. The test uses the 2024 US spring-forward on March 10 (02:00 EST -> 03:00 EDT): a purchase at 01:30 must close at 01:30 seven days later, not at 00:30 as a naive Add(168*time.Hour) would produce. This pins the correct behaviour and documents why a fixed-duration approach would be wrong. TestRevokePurchase_FourEyesApproval: skipped placeholder for the revoke 4-eyes approval gate tracked in issue #1005. The skip keeps the suite green while the feature is in flight and serves as a reminder to fill in the implementation when #1005 lands. * fix(api/purchases): map scheduleApprovedExecution CAS race to 409 (not 500) When a concurrent Cancel flips the execution away from schedulable status between the approve flow check and the CAS write, approveWithDelay now returns 409 instead of 500. ErrExecutionNotInExpectedStatus from TransitionExecutionStatus is the discriminator; any other error continues to surface as 500. * fix(api/purchases/revoke): drop pre-check, distinguish GetExecutionByID errors Two related fixes on the same line (revokePurchase GetExecutionByID branch): Finding B: Remove the racy status=="scheduled" pre-check. The old code read the status from the DB and then only dispatched to revokeScheduledExecution when it was "scheduled", but a concurrent writer could flip the status between the read and the CancelScheduledExecutionAtomic call. Drop the pre-check and let CancelScheduledExecutionAtomic's WHERE status='scheduled' CAS decide; a lost CAS returns 410 as expected. Finding C: Distinguish a genuine DB error (non-nil execErr) from a missing row (nil, nil). Before the fix, any non-nil execErr was folded into the "execErr == nil && ..." condition and silently swallowed, falling through to the history lookup. Now a non-nil execErr surfaces immediately as a wrapped error (500). * fix(api/purchases/revoke): clear revocation_in_flight on Azure error paths (transient retryable) When the Azure Return call fails (window-edge, client-error, or transient), the revocation_in_flight flag was left stuck at true. The finalize_revocations sweep would then treat the row as "Azure succeeded, DB write pending" and retry MarkPurchaseRevoked unnecessarily, potentially marking a purchase as revoked when it was never actually returned. Fix: call ClearRevocationInFlight (new store method) on all Azure Return error paths so the row reverts to its original status. On success the flag stays true for the sweep to handle (existing wave-1 behaviour unchanged). Adds ClearRevocationInFlight to StoreInterface, PostgresStore, and MockConfigStore. * fix(frontend/history): expose Revoke button for status='scheduled' rows + regression test Three related changes to surface the Revoke button on Gmail-style pre-fire delayed executions (status='scheduled') in the History UI: 1. Add "scheduled" to historyExecutionStatuses so these rows appear in the /api/history response at all (they were previously invisible). 2. Populate RevocationWindowClosesAt from ScheduledExecutionAt for scheduled rows in annotateHistoryRowByStatus so the frontend window check (revocation_window_closes_at in the future) works without a new field. 3. Update canRevokeCompletedRow to accept status==="scheduled" in addition to "completed" and "" (legacy blank), so the Revoke button renders. Adds regression test: a scheduled Azure row with a future revocation window must show the Revoke button (Findings E + G, second-wave CR). * fix(test/purchases/revoke): fix AssertNotCalled placement + isolate parallel test backoff state Finding F-1: Move AssertNotCalled(t, "CancelExecutionAtomic") to after the handler call in TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS. The assertion was placed before h.revokePurchase(), where it trivially passes regardless of what the handler does. Finding F-2: Drop t.Parallel() from TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries. The test mutates the package-global revokeMarkRetryBackoffs slice; running it in parallel with other tests that read the same variable is a data race. The test already uses t.Cleanup to restore the original value, which is sufficient when run sequentially. * fix(test/history-revoke): add missing mock entries for escapeHtmlAttr and getAmortizeUpfront The state mock lacked getAmortizeUpfront/setAmortizeUpfront/subscribeAmortizeUpfront and the utils mock lacked escapeHtmlAttr/amortizedMonthly. Both are called inside renderHistoryList; the missing entries caused a TypeError that loadHistory's try/catch swallowed, silently producing an empty history-list and hiding the Revoke button for all three "shows Revoke" cases. * fix(purchases/revoke): let the CAS decide scheduled cancellability (#290) Remove the early window-expiry 410 in revokeScheduledExecution. A row still in status="scheduled" has not been transitioned by the scheduler, so the cloud SDK call has not fired regardless of how far scheduled_execution_at is in the past (scheduler lag / backpressure). Returning 410 purely on a past timestamp broke free-cancel during lag even though CancelScheduledExecutionAtomic could still cancel the row before any cloud call. Let the CAS be the sole arbiter: it returns cancelled=false (410) only when the row has actually moved out of "scheduled". Updates the former WindowExpired test to assert the new contract (a past-timestamp scheduled row is cancelled for free via the CAS), and refreshes two stale "window-check" comments. Closes the last open CodeRabbit thread on PR #804. * fix(email/revoke): require recipient for scheduled-delay email; tidy tests (#290) Address the second CodeRabbit review pass on PR #804: - Security: SendPurchaseScheduledNotification (SES Sender) no longer falls back to the broadcast SendNotification path when RecipientEmail is empty. That email embeds a live, execution-scoped revoke link, so broadcasting it leaked an action link to every alert subscriber and broke the ownership/RBAC model around revocation. It now returns ErrNoRecipient, matching SendScheduledPurchaseNotification and the SMTP sender. Adds a regression test asserting ErrNoRecipient on empty recipient. - Test: rename TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed -> TestCallAzureReturn_JustOutsideSafetyMargin and correct its docstring; it drives callAzureReturn directly and never exercised the local 1h safety-margin gate (which lives in dispatchProviderRevoke). The reject side of that gate stays covered end-to-end via TestRevokePurchase_AzureWithinSafetyMarginRejected. - Build: implement the ClearRevocationInFlight StoreInterface method on the standalone analytics and server test mocks (mockConfigStore, mockConfigStoreForExchange, mockConfigStoreForHealth) so those test packages compile after the interface gained the method. * refactor(purchases): reduce cyclomatic complexity below the pre-commit 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).
Summary
offering_class,listing_id,listing_statecolumns topurchase_historyPOST /api/purchases/{id}/marketplace-listandPOST /api/purchases/{id}/marketplace-cancelroutes withsell-any/sell-ownRBAC (mirroring the cancel/retry/approve family);sell-ownadded toDefaultUserPermissions; default price schedule is 95% of residual value (upfront + monthly * remaining months); EC2 API:CreateReservedInstancesListing,DescribeReservedInstancesListings,CancelReservedInstancesListinglisting_state == "active"; confirm-dialog + API call + toast + reload click handlers; all API types exported fromapi/index.tsTest plan
go test ./...- all 4966 tests pass across 38 packagesnpm testinfrontend/- all 2142 frontend tests passnpm run build- compiles with no TS errorsoffering_class=standardand verify "Sell on Marketplace" button appears; click it and confirm the API call is dispatchedlisting_state=activeand cancels correctlysell-ownusers can only sell RIs in their allowed accounts;sell-anyusers can sell anyScope / descope
This PR ships listing creation and cancellation for Standard RIs. Two parts of #292 are intentionally descoped to keep it landable and are tracked in follow-up #966:
active -> closed/sold transition, realized proceeds, AWS fee actually charged).Because the poller is not implemented, migration 000060 was reduced to 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) were removed so the schema never carries columns nothing populates; they will land with the poller in LeanerCloud/cloud-commitments-platform#27.Closes #292 partially; remainder tracked in LeanerCloud/cloud-commitments-platform#27.
Summary by CodeRabbit
Release Notes