Skip to content

feat(marketplace): sell/cancel Standard RIs on AWS Marketplace (closes #292) - #808

Merged
cristim merged 21 commits into
mainfrom
fix/292-wave4
Jul 17, 2026
Merged

feat(marketplace): sell/cancel Standard RIs on AWS Marketplace (closes #292)#808
cristim merged 21 commits into
mainfrom
fix/292-wave4

Conversation

@cristim

@cristim cristim commented May 28, 2026

Copy link
Copy Markdown
Member

Summary

  • Migration 000060: adds offering_class, listing_id, listing_state columns to purchase_history
  • Backend: POST /api/purchases/{id}/marketplace-list and POST /api/purchases/{id}/marketplace-cancel routes with sell-any/sell-own RBAC (mirroring the cancel/retry/approve family); sell-own added to DefaultUserPermissions; default price schedule is 95% of residual value (upfront + monthly * remaining months); EC2 API: CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing
  • Frontend: "Sell on Marketplace" button on completed Standard RI rows; "Cancel listing" button when listing_state == "active"; confirm-dialog + API call + toast + reload click handlers; all API types exported from api/index.ts

Test plan

  • Run go test ./... - all 4966 tests pass across 38 packages
  • Run npm test in frontend/ - all 2142 frontend tests pass
  • Frontend build: npm run build - compiles with no TS errors
  • Smoke-test: create a Standard RI execution row with offering_class=standard and verify "Sell on Marketplace" button appears; click it and confirm the API call is dispatched
  • Verify the "Cancel listing" button appears when listing_state=active and cancels correctly
  • Verify Convertible RI rows and non-completed rows do NOT show the Sell button
  • Verify sell-own users can only sell RIs in their allowed accounts; sell-any users can sell any

Scope / 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:

  • Status poller (active -> closed/sold transition, realized proceeds, AWS fee actually charged).
  • Seller-account precondition UI (registered seller / US bank account on file) before listing.

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 #966.

Closes #292 partially; remainder tracked in #966.

Summary by CodeRabbit

Release Notes

  • New Features
    • Users can sell eligible AWS Reserved Instances on the marketplace with configurable pricing tiers
    • Added support for canceling active marketplace listings
  • Enhancements
    • Marketplace sell/cancel availability is restricted to eligible listings and appropriate permissions
    • The sell flow shows remaining term, estimated AWS fees, and estimated net proceeds
    • Actions are disabled during in-flight operations to prevent conflicts
  • Tests
    • Added API, client, and handler test coverage for marketplace listing creation and cancellation

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/low Minor harm urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/feat New capability labels May 28, 2026
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 57 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0283cf07-f294-4978-8ecb-5b8fd68fc305

📥 Commits

Reviewing files that changed from the base of the PR and between 3249ce9 and 1ae5175.

⛔ Files ignored due to path filters (1)
  • frontend/src/permissions.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (22)
  • frontend/src/__tests__/history-marketplace-sell-button.test.ts
  • frontend/src/__tests__/permissions.test.ts
  • frontend/src/api/index.ts
  • frontend/src/api/purchases.ts
  • frontend/src/history.ts
  • frontend/src/permissions.ts
  • frontend/src/types.ts
  • internal/analytics/collector_test.go
  • internal/api/handler.go
  • internal/api/handler_marketplace.go
  • internal/api/handler_marketplace_test.go
  • internal/api/router.go
  • internal/auth/service_group_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000087_purchase_history_marketplace_listing.down.sql
  • internal/database/postgres/migrations/000087_purchase_history_marketplace_listing.up.sql
  • internal/database/postgres/migrations/000087_purchase_history_marketplace_listing_test.go

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This 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.

Changes

AWS RI Marketplace Listing Implementation

Layer / File(s) Summary
Authorization Model and Permission Surface
internal/auth/types.go, internal/auth/types_test.go, frontend/src/permissions*, frontend/src/__tests__/permissions.test.ts
Adds sell-own and sell-any actions, grants sell-own:purchases to default users, and updates permission tests.
Marketplace Data Fields and Store Contracts
frontend/src/types.ts, internal/config/types.go, internal/config/interfaces.go, internal/database/postgres/migrations/000068_*
Adds marketplace metadata to purchase-history models and creates the corresponding database columns.
Postgres Purchase History Persistence
internal/config/store_postgres.go, internal/config/store_postgres_pgxmock_test.go
Extends purchase-history inserts, queries, updates, nullable scanning, and pgxmock coverage.
AWS EC2 Marketplace Client Operations
providers/aws/services/ec2/client.go, providers/aws/services/ec2/client_test.go
Adds create, describe, and cancel marketplace operations with request validation and client tests.
Marketplace Handler Validation and Execution
internal/api/handler*.go, internal/api/handler_marketplace_test.go
Adds authenticated validation, RBAC, price-schedule resolution, AWS error mapping, listing/cancel execution, persistence, rollback, and tests.
Route Registration and Frontend API Wiring
internal/api/router.go, frontend/src/api/purchases.ts, frontend/src/api/index.ts
Registers marketplace routes and exposes typed frontend API wrappers.
History Row Marketplace Actions
frontend/src/history.ts
Adds eligibility checks, action rendering, pricing confirmation, and sell/cancel handlers.
Mock Store Interface Updates in Tests
internal/analytics/collector_test.go, internal/mocks/stores.go, internal/server/test_helpers_test.go
Updates test doubles for the expanded config-store interface.

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
Loading

Possibly related PRs

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers listing creation/cancellation and RBAC, but it omits required status polling, proceeds tracking, seller-account validation, and IAM bundle updates from #292. Add the missing marketplace polling, proceeds/state persistence, seller-account precondition handling, and IAM bundle/runtime updates required by #292.
Docstring Coverage ⚠️ Warning Docstring coverage is 48.98% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding AWS Marketplace sell/cancel support for Standard RIs.
Out of Scope Changes check ✅ Passed The changes are largely aligned with the Marketplace feature and supporting tests, mocks, migrations, and permissions updates.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/292-wave4

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

@cristim

cristim commented May 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 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 win

Don'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 > 0 never 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4956d66 and 8bab8ca.

📒 Files selected for processing (24)
  • frontend/src/api/index.ts
  • frontend/src/api/purchases.ts
  • frontend/src/history.ts
  • frontend/src/types.ts
  • internal/analytics/collector_test.go
  • internal/api/handler.go
  • internal/api/handler_marketplace.go
  • internal/api/mocks_test.go
  • internal/api/router.go
  • internal/auth/service_group_test.go
  • internal/auth/service_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql
  • internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql
  • internal/mocks/stores.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go
  • providers/aws/services/ec2/client.go

Comment thread frontend/src/history.ts
Comment thread frontend/src/history.ts Outdated
Comment thread internal/api/handler_marketplace.go Outdated
Comment thread internal/api/handler_marketplace.go Outdated
Comment thread internal/api/handler_marketplace.go
Comment thread providers/aws/services/ec2/client.go
@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

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

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed summary of the fixes in 03ab7f574! Triggering a fresh incremental review now to cover the updated commit.

(ᴗ˳ᴗ)

✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

All 8 CodeRabbit findings from the 2026-05-28 review are resolved in commits a3cfc89 to 03ab7f5. Verification summary:

frontend/src/history.ts

  • Remaining-term gate: canSellOnMarketplace computes elapsed months from purchase timestamp and returns false when fewer than 1 month remains (line 498-504).
  • Lineage + marketplace composability: trailing actions are built before the lineage join, so retry descendants with eligible listings still get Sell/Cancel buttons (line 624-635).
  • Pricing modal: sell handler builds a DOM-based modal showing RI summary, remaining term, list price (5% below residual value), 12% fee, and estimated net proceeds before calling api.createMarketplaceListing (lines 938-985).

internal/api/handler_marketplace.go

  • Remaining-months accuracy: computeRemainingMonths(row.Timestamp, row.Term) is used instead of raw row.Term so default price schedules reflect actual residual value.
  • AWS error mapping: mapAWSMarketplaceError inspects smithy.APIError fault + known client-fault codes and returns 4xx for client errors, 502 for AWS-side failures.
  • DB failure on listing create: compensating CancelMarketplaceListing rollback attempted; both DB error and rollback outcome are logged; internal error returned to caller instead of success.

internal/database/postgres/migrations/000060_...

  • Added listed_at TIMESTAMPTZ, listing_price_schedule JSONB, listing_proceeds_received NUMERIC, listing_fee_paid NUMERIC to the ALTER TABLE.

providers/aws/services/ec2/client.go

  • CreateReservedInstancesListing: errors on empty listing ID.
  • DescribeMarketplaceListing / CancelMarketplaceListing: falls back to caller-supplied ID when AWS omits it in the response.

Tests: go build ./... clean, go test ./internal/api/... 1355 passed, go test in ec2 submodule 39 passed, frontend Jest 179 passed.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the thorough verification summary — every finding is clearly mapped to the exact lines and commit range. The compensating rollback on DB failure, the AWS error mapping via mapAWSMarketplaceError, the remaining-months accuracy fix, and the extra migration columns all look well-considered. Triggering a fresh incremental review now.

(ᴗ˳ᴗ)

✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

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:

  • Thread 3319800619 (canSellOnMarketplace remaining-term gate): confirmed at 3336658526. Code at history.ts:498-504 computes elapsedMonths from purchase timestamp and returns false when remainingMonths < 1.
  • Thread 3319800649 (pricing modal for sell flow): confirmed at 3336658686. Sell handler builds a dl summary with RI ID, region, resource type, remaining term, default list price (residual value x 0.95), 12% AWS fee, and estimated net proceeds before calling createMarketplaceListing.
  • Thread 3319800653 (default listing price uses full-term cost): confirmed at 3336658817. computeRemainingMonths(row.Timestamp, row.Term) used in both marketplaceList and marketplaceCancel handlers.
  • Thread 3319800660 (AWS marketplace errors mapped to 502): confirmed LGTM at 3336659205. mapAWSMarketplaceError uses smithy.APIError.ErrorFault() == smithy.FaultClient as primary classifier, plus a deny-list of known client-fault codes.
  • Thread 3319800663 (success returned after failed DB persistence): confirmed at 3336659846. Compensating CancelMarketplaceListing call on UpdatePurchaseHistoryListing failure; both error paths return an internal error.
  • Thread 3319800664 (missing settlement columns in migration): confirmed at 3336660174. Migration 000060 now adds all 7 columns: offering_class, listing_id, listing_state, listed_at, listing_price_schedule, listing_proceeds_received, listing_fee_paid.
  • Thread 3319800673 (empty listing IDs returned from ec2 client): confirmed at 3335986569 and 3336658526. Create path errors on empty ID; Describe/Cancel paths fall back to caller-supplied listingID.

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

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the meticulous resolution log — every thread is mapped to a specific commit reference and line range, making it easy to verify. All 7 findings plus the outside-diff composability note are clearly closed out, and the clean build + test results across all three suites look solid. Triggering a fresh incremental review now.

(ᴗ˳ᴗ)

✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased on feat/multicloud-web-frontend (18 base commits since last rebase).

Conflict resolution:

  • internal/auth/service_group_test.go (4 blocks): base migrated to group-membership-only authz (Revamp authorization: group-membership-only (remove roles), require >=1 group per user #907). Adopted base semantics: where the test setup includes a standard-group with DefaultUserPermissions(), the count is now 12 (standard-group, which already contains sell-own #292) + N (other groups) = 12+N. Where the test setup gives the user no standard-group or no groups at all, the empty-permission assertion from base is the correct outcome.
  • internal/auth/service_test.go (1 block): same shape. Sole-group-fails test now correctly asserts Empty(t, permissions) since there is no role-based fallback.
  • internal/api/handler_marketplace.go (rebase compile fix): session.Role == "admin" is gone in the group-only model; both authorizeSessionSell and authorizeAllowedAccount now check admin via h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionAdmin, auth.ResourceAll) matching the pattern used in middleware.go.

Verification:

  • gofmt -l . clean
  • go vet ./... clean
  • go build ./... clean
  • go test across all touched packages (internal/auth, internal/api, internal/config, internal/purchase, internal/scheduler, internal/analytics, internal/server, internal/mocks, providers/aws/services/ec2): 3292 passed, 0 failed
  • npx tsc --noEmit (frontend): clean
  • Frontend Jest history suite: 180 passed, 0 failed

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 (history.ts:610-624 lineage gate) is also addressed by the existing commits: line 634 now returns [...lineage, ...trailingActions].join(' ') so the Sell/Cancel buttons render even on retry descendants.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the thorough rebase summary! A few things stand out as well-considered:

  • The session.Role == "admin"h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionAdmin, auth.ResourceAll) change in handler_marketplace.go is the right alignment with the group-membership-only authz model from #907 — it ensures marketplace sell/cancel authorization goes through the same permission-graph path as the rest of the middleware.
  • The internal/auth/service_group_test.go conflict resolution (treating standard-group as the permission carrier and asserting Empty for users with no group) correctly reflects the new semantics where role-based fallback no longer exists.

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
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Actions performed

Review triggered.

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

@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Rebased fix/292-wave4 onto the latest feat/multicloud-web-frontend (now includes the base build fix from #941 and #926). go build ./... succeeds; gofmt/vet clean; touched-package tests green.

The prior round's 7 CR findings were addressed in the branch history:

  • history.ts early-return suppressing marketplace actions for retry descendants -> fixed in 2e22d55 (frontend findings commit, replayed post-rebase)
  • handler_marketplace.go HTTP status classification (502 vs 4xx) -> fixed in the Go handler findings commit
  • handler_marketplace.go DB-persist-failure-after-AWS-listing rollback -> fixed in the Go handler findings commit
  • handler_marketplace.go remaining-months pricing (row.Term overpricing) -> fixed in the Go handler findings commit
  • migration 000060 missing settlement columns (listed_at, listing_price_schedule, listing_proceeds_received, listing_fee_paid) -> fixed in the settlement-columns migration commit
  • ec2/client.go empty ListingID guard -> fixed in the validate-non-empty-listing-ID commit
  • canSellOnMarketplace remaining-term gate + pricing modal -> addressed in the frontend findings commit

Note: internal/auth/TestLogin_WithMFA_NoSecret/wrong_password_returns_same_generic_error fails identically on the base branch itself (this PR touches neither internal/auth/service.go nor service_test.go), so it is a pre-existing base inconsistency, not a regression from this PR.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed rebase summary and the clean verification signal. The mapping of all 7 prior findings to specific commits is clear, and the note about TestLogin_WithMFA_NoSecret/wrong_password_returns_same_generic_error being a pre-existing base inconsistency (untouched by this PR's file set) is well-reasoned. Triggering a fresh incremental review now to cover the rebase commits.

(ᴗ˳ᴗ)


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8bab8ca and 2330fc6.

📒 Files selected for processing (25)
  • frontend/src/api/index.ts
  • frontend/src/api/purchases.ts
  • frontend/src/history.ts
  • frontend/src/permissions.generated.ts
  • frontend/src/types.ts
  • internal/analytics/collector_test.go
  • internal/api/handler.go
  • internal/api/handler_marketplace.go
  • internal/api/mocks_test.go
  • internal/api/router.go
  • internal/auth/service_group_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.down.sql
  • internal/database/postgres/migrations/000060_purchase_history_marketplace_listing.up.sql
  • internal/mocks/stores.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go
  • providers/aws/services/ec2/client.go
  • providers/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

Comment thread internal/api/handler_marketplace.go Outdated
@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Pushed a follow-up fix: extracted scanPurchaseHistoryRow from queryPurchaseHistory in internal/config/store_postgres.go to bring the function back under the gocyclo budget of 10 (it had reached 11 after the nullable marketplace columns + nullable monthly_cost handling were added to the per-row scan). Behavior unchanged; gofmt/vet/build/config-tests green. This was the only CI failure on the rebased branch (the AWS/Azure Sanity and Frontend Build checks are green).

@coderabbitai review

cristim added 14 commits July 17, 2026 15:24
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).
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.
cristim added a commit that referenced this pull request Jul 17, 2026
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).
@cristim

cristim commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

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

@cristim
cristim merged commit bef2d0e into main Jul 17, 2026
20 checks passed
cristim added a commit that referenced this pull request Jul 17, 2026
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).
cristim added a commit that referenced this pull request Jul 17, 2026
… 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.
cristim added a commit that referenced this pull request Jul 17, 2026
… 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.
cristim added a commit that referenced this pull request Jul 17, 2026
… (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.
cristim added a commit that referenced this pull request Jul 20, 2026
…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.
cristim added a commit that referenced this pull request Jul 22, 2026
…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.
@cristim
cristim deleted the fix/292-wave4 branch July 27, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(history): Sell-on-Marketplace button for Standard RIs (CreateReservedInstancesListing) with status polling + sell-{any,own} RBAC

1 participant