Skip to content

feat(purchases): in-app revocation within free-cancel window (closes #290) - #804

Merged
cristim merged 31 commits into
feat/multicloud-web-frontendfrom
fix/290-wave3
Jun 9, 2026
Merged

feat(purchases): in-app revocation within free-cancel window (closes #290)#804
cristim merged 31 commits into
feat/multicloud-web-frontendfrom
fix/290-wave3

Conversation

@cristim

@cristim cristim commented May 28, 2026

Copy link
Copy Markdown
Member

Summary

Implements in-app purchase revocation with two complementary layers:

  1. Pre-fire delay (primary path, new): defer the cloud-provider SDK call by a configurable window (default 48h). Revoking during that window is a local DB cancel — zero provider involvement, zero cost, works identically for AWS / Azure / GCP.
  2. Provider-cancel API fallback (the original feat(purchases): in-app revocation within free-cancel window (closes #290) #804 design): if the row has already moved past the delay window to completed, the existing Azure CalculateRefund + Return SDK call still applies. AWS/GCP return 422 here (no public cancel API).

Closes #290.

The Gmail-style pre-fire delay (primary path)

Setting Default Range Where
PurchaseDelayHours 48 [0, 168] global config (Settings UI)

Flow

approve  ─►  DB row {status=scheduled, scheduled_execution_at=now+delay}  ─►  email user immediately
                                                                       ╲
                                                                        ╲ within window: user clicks revoke ─► status=cancelled, no SDK call, $0 spent
                                                                        ╱
                                                       scheduler tick (scheduled_execution_at <= now)
                                                                        ╲
                                                                         ╲ SDK call ─► status=completed ─► confirmation email (no revoke link)
                                                                        past window: revoke falls through to provider-API path (Azure works, AWS/GCP 422)
  • PurchaseDelayHours = 0 preserves the legacy immediate-execute behavior — opt-in for users who don't want any delay.
  • Idempotent scheduler: tick scans WHERE status='scheduled' AND scheduled_execution_at <= NOW(). Uses an owner-token compare-and-clear pattern so two overlapping ticks can't fire the same row twice.

Why this is the right shape

The original #804 design called the provider cancel API after the SDK purchase had already settled. That works on Azure (7-day refund window) but not on AWS / GCP, which have no public cancel API. Deferring our own SDK call sidesteps the asymmetry entirely — for the common case ("I just clicked approve and changed my mind"), no provider cooperation is needed.

Provider-cancel API fallback (the original #804 design)

Still wired up for rows that pass scheduled_execution_at without being revoked. Useful when the user wants delay=0, or returns a week later.

  • Azure: two-step CalculateRefund + Return via armreservations SDK, 7-day window. Button shown only for Azure rows within the window.
  • AWS / GCP: no public cancel API; endpoint returns 422 and the History UI hides the button.

Schema (migration 000065)

ALTER TABLE purchase_history ADD COLUMN IF NOT EXISTS (idempotent):

CHECK constraints enforce audit consistency:

  • revoked_via ∈ {known values, …} or NULL.
  • support_case_id non-null only when revoked_via = 'support-case'.
  • revoked_at and revoked_via are jointly null or jointly non-null.

Backend

  • POST /api/purchases/{id}/revoke route at AuthUser level.
  • Fail-closed: auth nil → 403 ClientError before any store or session call.
  • RBAC: revoke-own:purchases / revoke-any:purchases. revoke-own granted by default to all users (mirrors approve-own). Ownership uses account-access (GetAllowedAccountsAPI) because history rows pre-date created_by_user_id.
  • Idempotency: revoked_at IS NULL guard in MarkPurchaseRevoked. Already-revoked returns status: already_revoked without re-firing.
  • Azure error classification: 400/409/422 + policy keywords map to 400; transient faults propagate as 5xx.
  • Scheduler: new periodic task in internal/scheduler/scheduler.go for scheduled-purchase fire. Owner-token CAS protects against overlapping ticks.

Frontend

  • canRevokeCompletedRow: provider-agnostic for scheduled status (pre-fire window); Azure-only for completed status (provider window). Both gated on session + not yet revoked.
  • Revoke button rendered in History action cell; escapeHtml on purchase_id.
  • Destructive confirm dialog + api.revokePurchase() + success toast + history refresh.
  • New row state badge for scheduled (e.g. "Scheduled to execute in 47h — revoke link sent to email").

Tests

  • 10+ unit tests for the revoke handler (nil auth, empty ID, not found, already revoked, AWS 422, GCP 422, Azure outside window, Azure success via injected stubs, Azure calc-refund client error, Azure return server error).
  • Pre-fire delay path: scheduled status set on approve, SDK NOT called, revoke during window flips to cancelled.
  • Scheduler tick: scheduled_execution_at <= now fires SDK, transitions to completed.
  • parseAzureReservationIDs table tests, authorizeSessionRevoke RBAC matrix.
  • Frontend: regression test for legacy blank-status Azure rows + scheduled-state rendering.

Test plan

  • go test ./... — all packages green
  • npm test in frontend/ — all suites green
  • Manual: approve a purchase with PurchaseDelayHours = 1h, verify email arrives immediately, status is scheduled. Click revoke from email → status flips to cancelled, no SDK call.
  • Manual: approve with PurchaseDelayHours = 0, verify legacy immediate-execute behavior unchanged.
  • Manual: let a scheduled purchase pass its scheduled_execution_at; verify scheduler fires SDK, status → completed, confirmation email arrives without revoke link.
  • Manual: completed Azure row within 7 days still shows Revoke button (fallback path); AWS/GCP completed rows don't.

Related

Summary by CodeRabbit

  • New Features
    • Revoke completed Azure purchases from History during a free‑cancel window (includes revoke button, confirmation flow, success/error toasts).
    • Scheduled (delayed) approvals: approval can be scheduled, with email notifications containing a one‑click revoke link and window timing.
    • Role‑based revoke controls: users with revoke permissions see the inline Revoke control; admins retain broader revoke capabilities.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/feat New capability labels May 28, 2026
@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

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 2 minutes and 9 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

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

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: efd8b648-cdf3-49ee-848e-b394ed049e07

📥 Commits

Reviewing files that changed from the base of the PR and between 6486e1e and ee14a18.

📒 Files selected for processing (9)
  • internal/analytics/collector_test.go
  • internal/api/handler_history.go
  • internal/api/handler_purchases_revoke.go
  • internal/api/handler_purchases_revoke_test.go
  • internal/email/coverage_test.go
  • internal/email/templates.go
  • internal/server/handler.go
  • internal/server/handler_ri_exchange_test.go
  • internal/server/test_helpers_test.go
📝 Walkthrough

Walkthrough

Adds in-app revoke for Azure completed purchases plus a Gmail-style pre-fire delay that schedules approved purchases, stamps revoke windows, implements Azure CalculateRefund/Return revoke flow with TOCTOU and in-flight reconciliation, extends DB/store/schema and scheduler to fire scheduled executions and finalize revocations, updates frontend revoke UI/permissions/API, and aligns tests/mocks across suites.

Changes

Purchase Revocation & Pre-Fire Delay Feature

Layer / File(s) Summary
Contracts and permissions
internal/config/types.go, internal/auth/types.go, internal/auth/types_test.go, internal/auth/service_group_test.go
Adds PurchaseDelayHours, ScheduledExecutionAt, AzureRevocationWindowDays, RevocationWindowClosesAtFor, purchase-history revocation fields, and new permission actions revoke-own/revoke-any; updates default user permissions and tests.
Database schema and migrations
internal/database/postgres/migrations/000070_*, 000071_*, 000072_*, 000073_*
Adds purchase_delay_hours to global_config, scheduled_execution_at to purchase_executions and new scheduled status, revocation_window_closes_at, revoked_at, revoked_via, support_case_id, refund-audit columns, and revocation_in_flight with partial indexes and adjusted constraints.
Store interface and Postgres implementation
internal/config/interfaces.go, internal/config/store_postgres.go, internal/config/store_postgres_pgxmock_test.go
Extends StoreInterface with atomic cancellation and revocation helpers, implements SQL projections/scan updates, nullable-time mapping, GetScheduledExecutionsDue, CancelScheduledExecutionAtomic, MarkPurchaseRevoked, GetPurchaseHistoryByPurchaseID, and in-flight helpers; updates pgxmock tests.
Configuration validation
internal/config/validation.go
Validates PurchaseDelayHours within allowed range.
Authorization actions and permissions
internal/auth/types.go
Adds ActionRevokeOwn/ActionRevokeAny and grants revoke-own:purchases to default users.
Purchase revocation API handler
internal/api/handler_purchases_revoke.go, internal/api/handler_purchases_revoke_test.go, internal/api/router.go
Adds POST /api/purchases/{id}/revoke and GET /api/purchases/{id}/revoke/calculate, scheduled pre-fire cancel, completed-purchase Azure revoke (CalculateRefund+Return), window/safety checks, TOCTOU quote epsilon, in-flight reconciliation (207), RBAC/account scoping, and extensive tests.
Approval handler: pre-fire delay path
internal/api/handler_purchases.go, internal/api/handler_purchases_test.go
Refactors approval to schedule execution when PurchaseDelay enabled, atomically transitions to scheduled, stamps ScheduledExecutionAt and ApprovedBy, persists, and triggers scheduled-notification email; adds tests for scheduling and CAS behaviors.
Email system: scheduled notification
internal/email/interfaces.go, internal/email/nop_sender.go, internal/email/smtp_sender.go, internal/email/sender.go, internal/email/templates.go
Adds SendPurchaseScheduledNotification API, implements no-op and SMTP paths, extends NotificationData with RevocationWindowClosesAt and RevokeURL, and adds the scheduled-delay email template and sender logic.
Scheduler: fire scheduled purchases
internal/purchase/scheduled_fire.go, internal/purchase/scheduled_fire_test.go, internal/scheduler/scheduler.go
Implements FireScheduledDelayedPurchases to fetch and fire due scheduled executions via CAS transition and returns FireResult; adds unit tests and scheduler interface wiring.
Scheduler: finalize in-flight revocations
internal/purchase/finalize_revocations.go
Adds sweep to finalize revocation_in_flight rows with retry/backoff and returns FinalizeResult.
Scheduler and server integration
internal/server/handler.go, internal/server/handler_test.go, internal/server/interfaces.go, internal/testutil/mocks.go
Adds scheduled-task constants fire_scheduled_purchases and finalize_revocations, maps actions to tasks, dispatches to new handlers that call purchase manager methods, and updates test wiring and mocks.
Frontend permissions and types
frontend/src/permissions.ts, frontend/src/permissions.generated.ts, frontend/src/types.ts
Adds revoke-own/revoke-any action literals, includes revoke-own:purchases in generated USER_PERMS, and extends HistoryPurchase with optional revocation fields.
Frontend API and history UI
frontend/src/api/purchases.ts, frontend/src/api/index.ts, frontend/src/history.ts
Adds RevokePurchaseResult and revokePurchase() client, re-exports in API barrel, renders Revoke button gated by canRevokeCompletedRow, disables sibling row actions on in-flight, wires confirm dialog and API call, toasts, and history reload.
Frontend tests
frontend/src/__tests__/history-revoke-button.test.ts, frontend/src/__tests__/permissions.test.ts
Adds tests covering revoke button render matrix and permission expectations.
Purchase history stamping
internal/purchase/execution.go, internal/purchase/coverage_extra_test.go
Stamps Timestamp once per purchase and writes RevocationWindowClosesAt for Azure rows; adds regression test verifying the window computation.
Mock store refactor and test alignment
internal/mocks/stores.go, internal/api/mocks_test.go, internal/purchase/mocks_test.go, internal/scheduler/scheduler_test.go, internal/analytics/collector_test.go, internal/api/coverage_gaps_test.go, internal/server/*
Centralizes MockConfigStore, adds default-or-dispatch Fn overrides and revocation/scheduling methods, replaces local mocks with aliases, and adds email/store stubs across tests.
Go dependency
go.mod
Promotes armreservations Azure SDK to a direct dependency.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement, urgency/this-sprint, impact/all-users, effort/l

🐰 I scheduled a revoke and hopped away,
Seven days for Azure, two days to sway,
Buttons that guard, emails that chime,
A rabbit's small cheer for reclaiming time.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/290-wave3

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

cristim added a commit that referenced this pull request May 28, 2026
Three PRs were dispatched in parallel and each independently picked the
same "next free" migration number 000057. PR #802 (universal-plans
cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058
in a follow-up, and this PR (#804, revocation) now takes 000059.

- 000057_purchase_history_revocation.up.sql -> 000059_*
- 000057_purchase_history_revocation.down.sql -> 000059_*
- internal/config/store_postgres.go: comment ref "migration 000057"
  -> "migration 000059"
- migration file headers updated

Verified 000059 is free on origin/feat/multicloud-web-frontend and no
other open PR introduces a clashing migration.

Per `project_migration_number_collisions.md`.
@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

cristim added a commit that referenced this pull request Jun 1, 2026
@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

@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

@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 added a commit that referenced this pull request Jun 3, 2026
Three PRs were dispatched in parallel and each independently picked the
same "next free" migration number 000057. PR #802 (universal-plans
cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058
in a follow-up, and this PR (#804, revocation) now takes 000059.

- 000057_purchase_history_revocation.up.sql -> 000059_*
- 000057_purchase_history_revocation.down.sql -> 000059_*
- internal/config/store_postgres.go: comment ref "migration 000057"
  -> "migration 000059"
- migration file headers updated

Verified 000059 is free on origin/feat/multicloud-web-frontend and no
other open PR introduces a clashing migration.

Per `project_migration_number_collisions.md`.
cristim added a commit that referenced this pull request Jun 3, 2026
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto feat/multicloud-web-frontend (was 3 commits behind base; mergeStateStatus was DIRTY). Conflict resolution:

Fixed CI red lights from the previous push:

  • gofmt: realigned struct-field padding in TestRevokePurchase_AzureReturnClientError.
  • go mod tidy: promoted armreservations from indirect to direct (the revoke handler imports it directly post-rebase).
  • go vet / build: replaced session.Role == "admin" in authorizeSessionRevoke with session.UserID == apiKeyAdminUserID. The Role field was removed from api.Session by Revamp authorization: group-membership-only (remove roles), require >=1 group per user #907; the new short-circuit mirrors the sibling authorizeSessionCancel / authorizeSessionApprove patterns. Group-based admins still pass via the HasPermissionAPI({admin, *}) wildcard match further down. Test sessions updated accordingly.

Free-cancel window enforcement (AzureRevocationWindowDays + windowClosesAt) and revoke-call idempotency (early-return on record.RevokedAt != nil) are unchanged.

go build ./..., go vet ./..., go test ./... (5043 tests / 38 packages), gofmt -l ., and npx tsc --noEmit are all clean locally.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: Thanks for the detailed rebase notes. The conflict resolutions look sound — dropping the stale BuildAuthContext assertions that pre-dated #907 and the role-fallback assertion are the right calls given the group-membership-only authz model. The session.Role == "admin"session.UserID == apiKeyAdminUserID fix in authorizeSessionRevoke mirrors the established sibling patterns correctly. Kicking off a fresh review now.


🧠 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 added a commit that referenced this pull request Jun 4, 2026
Three PRs were dispatched in parallel and each independently picked the
same "next free" migration number 000057. PR #802 (universal-plans
cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058
in a follow-up, and this PR (#804, revocation) now takes 000059.

- 000057_purchase_history_revocation.up.sql -> 000059_*
- 000057_purchase_history_revocation.down.sql -> 000059_*
- internal/config/store_postgres.go: comment ref "migration 000057"
  -> "migration 000059"
- migration file headers updated

Verified 000059 is free on origin/feat/multicloud-web-frontend and no
other open PR introduces a clashing migration.

Per `project_migration_number_collisions.md`.
cristim added a commit that referenced this pull request Jun 4, 2026
@cristim
cristim force-pushed the fix/290-wave3 branch 2 times, most recently from 378a7de to a4e9e33 Compare June 4, 2026 11:45
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: Both fixes are solid:

  • The security fix on SendPurchaseScheduledNotification is the critical one — the broadcast fallback path would have sent a live, execution-scoped revoke link to every alert subscriber, bypassing the RBAC model entirely. Returning ErrNoRecipient and adding the PurchaseScheduledDelay_no_recipient regression test closes that gap correctly and mirrors the smtp_sender.go contract.
  • The test correction (rename + docstring) is clean hygiene — the safety-margin gate coverage split is now accurate: TestCallAzureReturn_JustOutsideSafetyMargin tests callAzureReturn directly, and TestRevokePurchase_AzureWithinSafetyMarginRejected covers the gate end-to-end. No gap in coverage.
  • The ClearRevocationInFlight build-break fix on the standalone mocks is the right approach.

Triggering a fresh incremental review now.

🐇 🔐

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

…t gate (#290)

The gocyclo pre-commit hook (threshold 10) failed CI on four functions
the #290 feature work grew past the limit. Split each into focused
helpers with no behavior change:

- calculateAzureRevoke (21): extract validateAzureRevokeRequest (auth +
  load + authorize + window/ID validation, itself split into
  azureRevokeWindowAndIDs) and extractAzureRefundQuote.
- callAzureReturn (21): extract azureCalculateRefund (CalculateRefund +
  parse), handleAzureReturnError (clear in-flight + status mapping), and
  persistAzureRevocation (MarkPurchaseRevoked retry + 207 result).
- annotateHistoryRowByStatus (12): move the in-flight / audit-gap cases
  into annotateInFlightOrAuditGapRow.
- dispatchTask (11): switch to a map-based dispatch.

gocyclo now reports nothing over 10; full internal test suite green
(except the pre-existing CSRF flake that also fails on base).
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

Follow-up: the Run pre-commit hooks CI check was failing on the gocyclo gate (threshold 10) for four functions the #290 work grew past the limit. Pushed ee14a181 to refactor them into focused helpers with no behavior change:

  • calculateAzureRevoke (21) -> validateAzureRevokeRequest + azureRevokeWindowAndIDs + extractAzureRefundQuote
  • callAzureReturn (21) -> azureCalculateRefund + handleAzureReturnError + persistAzureRevocation
  • annotateHistoryRowByStatus (12) -> annotateInFlightOrAuditGapRow
  • dispatchTask (11) -> map-based dispatch

gocyclo -over 10 now reports nothing; go test ./internal/... green except the pre-existing CSRF flake that also fails on base.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: Clean gocyclo remediation — the decompositions follow natural sub-task boundaries (validate → compute window/IDs → extract quote for the Azure revoke path; calculate → error classify → persist for the return path), so the behavior-neutral claim is credible. The map-based dispatch for dispatchTask is the standard Go idiom for this pattern. Triggering a fresh incremental review now.

🐇 🔧

✅ 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 8e5f139 into feat/multicloud-web-frontend Jun 9, 2026
5 checks passed
cristim added a commit that referenced this pull request Jun 9, 2026
…econflict (refs #694)

PRs #808 and #804 both occupy 000068 on feat/multicloud-web-frontend;
renumber this PR's migration to 000069 to remove the collision.
cristim added a commit that referenced this pull request Jun 9, 2026
…closes #1009)

Add transitioned_by/transitioned_at columns to purchase_executions,
ri_exchange_history, and account_registrations (migration 000066).
Thread an optional actor *string through all three TransitionStatus
methods so human-initiated transitions record the session user UUID
while system-initiated paths (reaper, scheduler, token-based approval)
pass nil. Guard non-UUID reviewer IDs (e.g. "admin-api-key") via
validUUIDPtrOrNil before using as a FK value. Mirrors the
CancelExecutionAtomic(actor) pattern from #804.
cristim added a commit that referenced this pull request Jun 9, 2026
Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation
base (#804) left two integration gaps that only surface once both feature
sets coexist:

- internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID
  twice: #804 added the func-override version and the marketplace commit added
  a no-op stub. Drop the marketplace stub; keep the override and the unique
  UpdatePurchaseHistoryListing stub so the mock builds.
- frontend permissions.test.ts asserted the user role grants 12 verbs; the
  merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus
  sell-own from #292). Add sell-own:purchases to the expected set.

The conflict resolution itself (handlers, store SELECT/scan column order,
auth defaults, mocks, frontend history buttons) was applied additively during
the rebase; both feature sets are independent (marketplace sell/cancel vs
revocation). Marketplace migration stays at 000068 (free gap vs the new base,
which tops out at 000070).
cristim added a commit that referenced this pull request Jun 9, 2026
…loses #291)

#804 (#290) added a session-authed in-app revokePurchase plus
handler_purchases_revoke.go; #291 added a token-based email one-click
revoke plus a public GET/POST /api/purchases/revoke/{id} route. Two
methods named revokePurchase collided after #804 merged.

Unify both into a single revokePurchase(ctx, req, id, token) entry point
in handler_purchases_revoke.go that dispatches on token presence and the
row's state, mirroring how approvePurchase / cancelPurchase already
handle token-OR-session:

- GET (any route): render the HTML confirmation form so email
  prefetchers cannot auto-trigger a mutation; only POST mutates.
- purchase_execution, status completed/partially_completed: the #291
  post-execution revoke, now revokeCompletedExecution. token-OR-session
  matrix; the email one-click link (token) and the dashboard Revoke
  button (session) both land here. Token path keeps the contact-email
  authz, the token expiry + 24h window + constant-time compare; session
  path keeps the cancel-any/cancel-own RBAC.
- purchase_execution, status scheduled: #290 free pre-fire cancel,
  session-only, scheduled-CAS arbitrated.
- pending/notified: friendly 409 directing the caller to Cancel.
- no execution row: 404 for the token (email) path; otherwise the
  session-only Azure reservation Return in purchase_history (#290).

Auth is fail-closed on every path: a nil auth service returns 403 before
any lookup, so neither the token nor the session path can be served
unauthenticated, and neither bypasses the window/state checks.

Both routes are kept and point at the unified handler:
/api/purchases/{id}/revoke (AuthUser, session) and the public
/api/purchases/revoke/{id} (AuthPublic, token). Email package keeps both
SendPurchaseScheduledNotification and SendPurchaseExecutedNotification;
NotificationData merges the two field sets, deduping
RevocationWindowClosesAt. No migration is added (#291 adds none; base
top is 000073).

Tests reconciled: both handler_purchases_revoke_test.go (#804) and the
revoke tests in handler_purchases_test.go (#291) adapt to the unified
signature; token scenarios (valid/invalid/expired, GET-no-mutate,
window-expired) and session scenarios (fail-closed, creator-scope,
scheduled-CAS race, Azure return) are all still asserted.
cristim added a commit that referenced this pull request Jun 9, 2026
…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).
cristim added a commit that referenced this pull request Jun 19, 2026
…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).
cristim added a commit that referenced this pull request Jun 19, 2026
…s SELECT

GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.

The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.

Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.

Closes #1247
cristim added a commit that referenced this pull request Jul 9, 2026
…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).
cristim added a commit that referenced this pull request Jul 10, 2026
…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).
cristim added a commit that referenced this pull request Jul 10, 2026
…s SELECT

GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.

The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.

Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.

Closes #1247
cristim added a commit that referenced this pull request Jul 10, 2026
…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).
cristim added a commit that referenced this pull request Jul 11, 2026
…s SELECT

GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27
(the 27th being scheduled_execution_at). Against a real PostgreSQL instance
this caused rows.Scan to fail ("expected 27 destination arguments in Scan,
not 26"), returning "failed to scan execution" and surfacing as a 500 on
the Planned Purchases list endpoint.

The regression drifted in via PR #804 (commit 8e5f139) which added
scheduled_execution_at to scanExecutionRows and every other SELECT feeding
it, but skipped GetPlannedExecutions.

Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns:
the mock ExpectQuery regexp now requires both idempotency_key AND
scheduled_execution_at in the SQL so future column-count drift is caught
at test time rather than in production.

Closes #1247
cristim added a commit that referenced this pull request Jul 16, 2026
…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).
cristim added a commit that referenced this pull request Jul 16, 2026
…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).
cristim added a commit that referenced this pull request Jul 17, 2026
…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).
cristim added a commit that referenced this pull request Jul 17, 2026
…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).
cristim added a commit that referenced this pull request Jul 17, 2026
…#292) (#808)

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

Add sell-on-Marketplace support for Standard Reserved Instances:

- migration 000060: add offering_class, listing_id, listing_state to purchase_history
- auth: sell-any and sell-own actions; sell-own added to DefaultUserPermissions
- config: extend PurchaseHistoryRecord, StoreInterface with GetPurchaseHistoryByPurchaseID + UpdatePurchaseHistoryListing
- ec2 client: CreateReservedInstancesListing, DescribeReservedInstancesListings, CancelReservedInstancesListing
- api: handler_marketplace.go with marketplaceList/Cancel, authorizeSessionSell (sell-any/sell-own RBAC), default price schedule (95% of residual value)
- router: POST /api/purchases/{id}/marketplace-list + /marketplace-cancel routes
- all tests updated for the new interface methods and permission count

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

Add Sell on Marketplace / Cancel listing buttons to purchase history rows:

- types.ts: extend HistoryPurchase with offering_class, listing_id, listing_state
- api/purchases.ts: createMarketplaceListing + cancelMarketplaceListing; re-export from api/index.ts
- history.ts: canSellOnMarketplace / canCancelMarketplaceListing predicates; renderActionCell renders Sell/Cancel buttons for Standard RI completed rows; wireRowActionHandlers wires confirm-dialog + API + toast + reload for both buttons

* fix(history): address CR wave4 frontend marketplace findings

- canSellOnMarketplace: gate on remaining term >= 1 month computed from
  purchase timestamp + total term; matured Standard RIs no longer show Sell
- sell click handler: open pricing/schedule modal before calling
  createMarketplaceListing so users see RI summary, default list price, and
  12% fee breakdown before confirming
- lineage actions cell: build trailingActions[] first then combine with
  lineage[] so Sell/Cancel buttons render on retry-descendant rows

* fix(marketplace): address CR wave4 Go handler findings

- Compute actual remaining months from purchase timestamp and total term
  via computeRemainingMonths; removes overpricing of older RIs in the
  default price schedule (was using full contract term)
- Map AWS errors via mapAWSMarketplaceError: inspect smithy.APIError code
  and fault; client-fault errors return 4xx instead of blanket 502
- On DB failure after successful listing creation, attempt compensating
  rollback via CancelMarketplaceListing to prevent AWS/DB desync; return
  internal error rather than false success to the caller
- Same DB-failure fix for cancel handler: return error on UpdatePurchaseHistoryListing
  failure so the caller knows the state is out of sync

* fix(migrations): add settlement columns to 000060 marketplace migration

Add listed_at, listing_price_schedule, listing_proceeds_received, and
listing_fee_paid columns for marketplace settlement tracking. All nullable,
consistent with existing offering_class/listing_id/listing_state nullability.
Down migration updated to drop the new columns.

* fix(ec2): validate non-empty listing ID from CreateReservedInstancesListing

Return an error when AWS returns a listing with an empty
ReservedInstancesListingId rather than propagating a blank ID that would
silently make rollback and describe calls fail.

* fix(pr-808): compile + CR findings + pre-commit

Repair the stalled marketplace sell/cancel work:

- Add the three missing marketplace methods to MockEC2Client so the
  ec2 package compiles against the extended EC2API interface
  (CreateReservedInstancesListing, DescribeReservedInstancesListings,
  CancelReservedInstancesListing).
- Extract validateMarketplaceListRequest from marketplaceList to bring
  its cyclomatic complexity back under the gocyclo threshold.
- Regenerate frontend/src/permissions.generated.ts to include the new
  sell-own:purchases permission (pre-commit codegen check).
- Harden Describe/Cancel marketplace paths to fall back to the
  caller-supplied listing ID when AWS omits ReservedInstancesListingId,
  so downstream persistence never stores an empty ID (CR finding).

* fix(marketplace): adapt to nullable PurchaseHistoryRecord.MonthlyCost

After rebasing onto feat/multicloud-web-frontend, base PR #258 made
PurchaseHistoryRecord.MonthlyCost a *float64 (nullable). The marketplace
default price-schedule path passed it as a float64. Dereference it
nil-safely at the call site, treating an absent monthly breakdown as a
zero recurring contribution to residual value (per the nullable-not-zero
convention: nil means "no breakdown recorded", which contributes nothing
to the residual list price).

* refactor(config): extract scanPurchaseHistoryRow to fix gocyclo budget

queryPurchaseHistory reached cyclomatic complexity 11 (budget 10) after the
nullable marketplace columns (offering_class, listing_id, listing_state) and
the nullable monthly_cost handling were added to the per-row scan. Pull the
scan plus nullable-column reconciliation into scanPurchaseHistoryRow so the
parent function drops back under the limit. Behavior is unchanged.

* fix(api/marketplace): prorate upfront cost to remaining term in default schedule

When the caller omits a price schedule, the default was computing
total value as (full_upfront + recurring * remaining_months).
For an older RI this overprices the upfront component because
only (remaining/original) of the upfront has not yet been
amortized. Change to:

  upfront_remaining = upfront_cost * (remaining / original_term)
  total_value = upfront_remaining + recurring * remaining_months

Pass row.Term as originalTerm through resolveMarketplacePriceSchedule.
Addresses CR finding on handler_marketplace.go.

* fix(marketplace): multi-count listing, handler tests, descope poller columns (closes #292 partial)

Address adversarial-review gaps on the RI Marketplace sell/cancel PR:

- Multi-count fix: CreateMarketplaceListing now lists every RI in the row
  (purchase_history.count) instead of a hardcoded InstanceCount=1, so a row
  of N Standard RIs lists all N. The EC2 client rejects a non-positive count
  before the outbound call; the handler passes row.Count (floored at 1 for
  legacy rows). Adds EC2 client tests for create (multi-count + guards),
  describe, and cancel via the SDK mock.

- Tests: add handler_marketplace_test.go covering convertible -> 400,
  sell-own allow + deny (account scoping), duplicate active listing -> 409,
  AWS error mapping (client fault -> 400, server/unknown -> 502), DB-failure
  compensating rollback, and default-schedule proration math.

- Descope poller: migration 000060 keeps only the columns the implemented
  flow reads/writes (offering_class, listing_id, listing_state). The
  settlement/poller columns (listed_at, listing_price_schedule,
  listing_proceeds_received, listing_fee_paid) are removed so the schema
  never carries columns nothing populates; they land with the poller in the
  #292 follow-up (#966).

- Repair a stale Session.Role="admin" reference in a pre-existing purchases
  test (removed in the group-only auth migration) so the api test package
  compiles after rebasing onto the current base.

* fix(migrations): renumber to 000065 to avoid base conflict

000060 is taken by 000060_cleanup_universal_plans on base.

* fix(migrations): renumber marketplace migration to 000068 to clear base conflict

The marketplace listing migration was numbered 000065, colliding with
000065_enforce_min_one_admin which has since landed on
feat/multicloud-web-frontend. The check-migration-conflicts pre-commit
hook fails on the merge ref with "Duplicate migration number(s): 000065".

Renumber to 000068 (one past base's highest, 000067) so the number is
unique on the merge ref, and update the two stale in-file comment
references from 000060 to 000068.

* fix(marketplace): reconcile mock + permission tests and gocyclo after #804 rebase

Rebasing the marketplace sell/cancel work (#292) onto the in-app revocation
base (#804) left integration gaps that only surface once both feature sets
coexist:

- internal/analytics/collector_test.go declared GetPurchaseHistoryByPurchaseID
  twice: #804 added the func-override version and the marketplace commit added
  a no-op stub. Drop the marketplace stub; keep the override and the unique
  UpdatePurchaseHistoryListing stub so the mock builds.
- frontend permissions.test.ts asserted the user role grants 12 verbs; the
  merged DefaultUserPermissions now grants 13 (revoke-own from #804 plus
  sell-own from #292). Add sell-own:purchases to the expected set.
- store_postgres.go: merging the revocation columns (#290) into the marketplace
  gocyclo refactor pushed scanPurchaseHistoryRow and GetPurchaseHistoryByPurchaseID
  back over the cyclomatic budget. Extract the shared NULL reconciliation into a
  purchaseHistoryNullables struct + applyTo, so both readers stay under 10 and
  the NULL handling lives in one place.

The conflict resolution itself (handlers, store SELECT/scan column order, auth
defaults, mocks, frontend history buttons) was applied additively during the
rebase; both feature sets are independent (marketplace sell/cancel vs
revocation). Marketplace migration stays at 000068 (free gap vs the new base,
which tops out at 000070).

* fix(marketplace): gate sell actions on sell verbs and prorate default price (CR #808)

Address CodeRabbit findings on the issue #292 marketplace sell/cancel flow:

- Gate canSellOnMarketplace / canCancelMarketplaceListing on the sell-own /
  sell-any (or admin) verbs instead of bare sign-in, so the UX gate matches
  the backend authorizeSessionSell and avoids frontend/backend auth drift.
  Adds sell-own / sell-any to the Action union, mirroring the backend
  ActionSellOwn / ActionSellAny constants.
- Prorate the upfront cost to its residual value in the sell modal's default
  price estimate (upfront * remaining/term), matching the backend
  resolveMarketplacePriceSchedule. Using the full upfront overstated the
  listing value for partially elapsed RIs.

Also correct stale "migration 000060" comments (the columns ship in 000068)
and add pgxmock coverage for GetPurchaseHistoryByPurchaseID asserting its
distinct 25-column scan order (revocation_in_flight at position 22).

* fix(marketplace): name fee/discount constants and restore gocyclo baseline

- Introduce awsMarketplaceFeePercent (12), awsMarketplaceNetFactor (0.88),
  and awsMarketplaceBuyerDiscountFactor (0.95) as named Go constants in
  handler_marketplace.go; replace the matching TS literals with
  AWS_MARKETPLACE_FEE_PERCENT / AWS_MARKETPLACE_NET_FACTOR /
  AWS_MARKETPLACE_BUYER_DISCOUNT in history.ts. Eliminates silent ratios on
  a money-affecting display path per the no-hardcoded-magic-values rule.
- Restore store_postgres_recommendations.go to main's lower-complexity form
  (recEffectiveSavingsPct + recOnDemandBaseline split); the rebase picked up
  an inlined variant from an earlier PR commit that cyclomatic-scored 12.

* refactor(api): derive awsMarketplaceNetFactor from fee percent

Replace the hardcoded 0.88 literal with a computed constant expression
(1 - awsMarketplaceFeePercent/100.0) so the net factor stays in sync
with the fee percent automatically. The Go constant evaluator uses
arbitrary precision, so the resulting float64 value is identical to the
literal it replaces; no money-math change.

* fix(marketplace): guard concurrent RI listing creates with atomic slot claim

Two concurrent marketplace-list requests for the same RI could both pass the
read-only listing_state check in validateMarketplaceListRequest and both call
AWS CreateReservedInstancesListing (each with a fresh ClientToken, so AWS does
not dedup them), leaving two live listings for one RI and overwriting the DB
row. Reserve the listing slot with a single atomic conditional UPDATE
(ClaimMarketplaceListingSlot, modeled on FlipPurchaseRevocationInFlight) before
the AWS call so exactly one racing request proceeds; the loser gets a 409. The
claim is released back to the prior state on every post-claim failure path so a
failed attempt never leaves the row stuck in the transient pending state.

Also on this money path:
- reject an implausibly large purchase count instead of silently truncating it
  into int32 (gosec G115) so the wrong number of RIs can never be listed;
- add listing-state constants matching the AWS ListingStatus enum and use them
  in place of scattered string literals;
- fix US-locale spellings flagged by misspell in comments and log/error text.

Adds regression tests reproducing the concurrent-create race (claim loses ->
409, no AWS call) and the claim-error path, plus release-on-failure assertions.

Closes #292 CR concurrent-create finding.

* fix(marketplace): resolve 3 blocking defects in #808 RI sell flow

Defect 1 -- migration number collision:
Renumber marketplace migration from 000068 (already deployed, skipped by
golang-migrate on prod) to 000084 (next free after main's 000083).
Verified: main tops at 000083; #1277 uses 000082; no open PR holds 000084.
All "000068" references in SQL comments and types.go updated to 000084.

Defect 2 -- offering_class never written:
CUDly's EC2 client hardwires OfferingClassTypeConvertible; savePurchaseHistory
never stamped the field, so every row landed with offering_class=NULL and the
Sell button never rendered.
Fix: (a) stamp offering_class='convertible' in savePurchaseHistory for all
AWS EC2 purchases so future CUDly-bought rows are correctly classified at write
time; (b) add FetchOfferingClass to the marketplaceEC2Client interface and
implement it via DescribeReservedInstances so the marketplace-list handler can
lazily populate offering_class for pre-migration rows and for
externally-created Standard RIs (purchased before CUDly existed); (c) add
StampOfferingClass to ConfigStore + PostgresStore + mock to persist the
fetched class so subsequent requests skip the extra AWS call; (d) a new
populateOfferingClass helper in the handler ties it together -- it runs when
offering_class is empty after validateMarketplaceListRequest and before the
definitive "standard only" gate.

How a 'standard' row comes to exist: an externally-created Standard RI will
get its offering_class populated from AWS DescribeReservedInstances on the
first POST .../marketplace-list call and the value persisted for future calls.
Rows purchased by CUDly are stamped 'convertible' at write time (CUDly only
ever buys Convertible EC2 RIs).

Defect 3 -- $0 price guardrail gap:
resolveMarketplacePriceSchedule accepted Price >= 0, allowing zero-dollar
listings. Fix: reject Price <= 0 with a clear error; add a named constant
awsMarketplaceMinPriceFloorFraction (5%) and a total-schedule floor check so
a schedule that sums to less than 5% of the prorated residual value is also
rejected with an explicit message. Extract floor check into
checkSuppliedScheduleFloor and the class-check predicate into
isKnownNonStandardOfferingClass to keep all touched functions below gocyclo 10.

Regression tests:
- TestMigration084_MarketplaceColumns: integration test proves the three new
  columns exist and round-trip after migrating a fresh DB through 000084.
- TestMarketplaceList_EmptyOfferingClassFetchedStandard: proves an
  externally-created Standard RI (offering_class="") is listable after the
  lazy-populate path fetches and stamps 'standard' from AWS.
- TestMarketplaceList_EmptyOfferingClassFetchedConvertible: proves the gate
  still rejects when AWS reports 'convertible' even if DB had no class.
- TestResolveMarketplacePriceSchedule_ZeroPriceRejected: Price=0 rejected.
- TestResolveMarketplacePriceSchedule_BelowFloorRejected: sub-floor rejected.

* fix(marketplace): renumber migration 000084 -> 000085 to avoid #1277 collision

PR #1277 (cancelled->canceled rename) was concurrently renumbered to 000084
and merges before #808. Once it lands, main will hold 000084, so #808's
000084 would collide on the merge ref (pre-commit --all-files migration
check) and give golang-migrate two version-84 migrations. Since #1277 takes
the lower number and merges first, #808 moves to 000085.

- git mv the up/down/test migration files 000084_* -> 000085_*
- update the "-- Migration" header in the up.sql and "-- Revert migration"
  in the down.sql to 000085
- update every 000084 reference in comments: config/types.go (x3),
  config/interfaces.go, config/store_postgres.go, api/handler_marketplace.go
  (x2), providers/aws/services/ec2/client.go
- rename TestMigration084_MarketplaceColumns -> TestMigration085_* and its
  fixture identifiers (ri-085-test, ril-085-test)

Also fix the integration-test fixture surfaced by running it against a real
testcontainer PG: plan_id is a UUID FK, so the literal 'plan-085' failed with
SQLSTATE 22P02. The INSERT now lists only NOT-NULL-without-default columns
plus the three new marketplace columns and omits plan_id/plan_name/ramp_step/
source (nullable or defaulted), so the test needs no purchase_plans fixture.
Verified: migrations apply cleanly through version 85 and the three columns
round-trip.

* fix(marketplace): correct RI listing price-floor math and reachability

Round-3 money-path review fixes for the Sell-on-Marketplace flow (#808).

Price floor (was arithmetically wrong):
- Enforce the floor PER TIER on the one-time sale Price. AWS
  PriceScheduleSpecification.Price is the lump-sum a buyer pays when
  TermMonths remain, not a per-month rate; the old code compared
  Price*TermMonths against the floor, over-crediting a cheap schedule up to
  ~term-fold so a $5 tier passed a $60 floor on a $1,200 residual.
- Reject any tier whose TermMonths exceeds the RI's remaining months (was
  unvalidated and arbitrarily inflatable).
- Per-instance basis: purchase_history.UpfrontCost is the row total for all
  Count instances, but Marketplace prices are per instance, so divide the
  residual by Count in both the floor and the default schedule.
- Upfront-only residual: drop recurring (monthly) cost from the residual
  basis. In the RI Marketplace the buyer assumes recurring charges after
  transfer, so the seller recovers only the upfront remainder; including
  recurring overpriced partial-upfront RIs and made no-upfront RIs unpriceable.
- Extract marketplaceResidualPerUnit as the shared per-unit basis so the
  default schedule and the floor cannot drift.

Reachability: render the Sell button for completed AWS EC2 rows whose
offering_class is empty (unknown), not just "standard". CUDly stamps
"convertible" on its own EC2 purchases and externally-created Standard RIs
arrive with an empty class until the backend lazily populates it; gating the
UI on "standard" alone made the feature unreachable end to end. Provider and
service are now checked so unknown-class non-EC2 rows never show the button.

Nits:
- Use SDK enum constants string(ec2types.OfferingClassType{Standard,Convertible})
  instead of raw "standard"/"convertible" literals in the offering-class gate,
  the isKnownNonStandardOfferingClass predicate, and the purchase-history stamp.
- Route populateOfferingClass errors through mapAWSMarketplaceError so an AWS
  client fault (e.g. InvalidReservedInstancesID.NotFound) surfaces as a 400,
  not a generic 500.

Migration: renumber 000085 -> 000087 to stay above #1422's 000086 (main tops
at pre-84; #1422 takes 000086, so this PR takes 000087).

Regression tests (fail-before / pass-after verified):
- TestResolveMarketplacePriceSchedule_BelowFloorRejected: a {12mo, $5} tier on
  a $1,200 per-unit residual (Count=1) is now rejected; passed under the old
  Price*TermMonths floor.
- TestResolveMarketplacePriceSchedule_PerUnitFloor: on a $1,200 row-total with
  Count=3 the per-unit floor is $20, so $19 is rejected and $21 accepted.
- TestResolveMarketplacePriceSchedule_TermExceedsRemainingRejected: a tier term
  beyond the remaining months is rejected.
- history-marketplace-sell-button.test.ts: the Sell button renders for a
  completed AWS EC2 row with an empty offering_class and stays hidden for
  convertible, non-EC2, non-AWS, active-listing, and anonymous cases.
- TestMigration087_MarketplaceColumns: fresh DB migrates cleanly through 000087
  and the three columns round-trip.
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/medium Moderate 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.

1 participant