feat(purchases): in-app revocation within free-cancel window (closes #290)#804
feat(purchases): in-app revocation within free-cancel window (closes #290)#804cristim wants to merge 17 commits into
Conversation
|
@coderabbitai review |
|
Warning Review limit reached
More reviews will be available in 38 minutes and 49 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (47)
📝 WalkthroughWalkthroughThis PR adds a complete purchase revocation feature allowing authenticated users to revoke completed Azure purchases within a 7-day free-cancel window. Changes span frontend UI (revoke button in history), backend API handler with Azure Reservations integration, RBAC authorization, database schema and storage, and comprehensive test coverage. ChangesPurchase Revocation Feature
🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
… complexity; regenerate permissions on PR #804
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
… complexity; regenerate permissions on PR #804
|
Rebased onto
Fixed CI red lights from the previous push:
Free-cancel window enforcement (
@coderabbitai review |
|
🧠 Learnings used✅ Actions performedReview triggered.
|
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
… complexity; regenerate permissions on PR #804
378a7de to
a4e9e33
Compare
…oss providers Adds status=scheduled to purchase_executions so the approval step defers the cloud SDK call by purchase_delay_hours. A scheduled execution can be cancelled at $0 via the existing revoke endpoint before the scheduler fires. Two-tier revoke path: - status=scheduled: CancelExecutionAtomic (CAS) transitions to cancelled; no cloud API call, returns 200 with explicit "no cost incurred" message. - status=completed (existing): provider SDK call via purchase_history path. revokePurchase now tries GetExecutionByID first; falls through to the existing purchase_history lookup when no scheduled execution is found. authorizeSessionRevokeExecution mirrors authorizeSessionRevoke but uses CreatedByUserID (not CloudAccountID) for revoke-own scoping. FireScheduledDelayedPurchases (purchase.Manager) drives the scheduler tick: queries GetScheduledExecutionsDue, CAS-transitions scheduled->approved, stamps ApprovedBy="scheduler", calls executeAndFinalize. Registered as TaskFireScheduledPurchases in the Lambda task dispatcher. Also folds in three CodeRabbit findings from PR #804 review f9c66c1e: - Remove unused mockAuth in two AzureCalcRefundClientError/ReturnClientError tests - Add idempotent audit CHECK constraints to migration 000065 (revoked_via, support_case_id, revoked_at/revoked_via pair) - Frontend regression test: legacy blank-status Azure rows stay revocable - analytics/collector_test.go: hook-backed GetPurchaseHistoryByPurchaseID and MarkPurchaseRevoked mocks (no more hardcoded no-ops)
Wave 3: Gmail-style pre-fire delay + CR findings addressedThis push adds the scheduled-execution revoke path and folds in the three CodeRabbit findings from review New feature: Gmail-style pre-fire delay (status=scheduled)When
CR findings addressed (review f9c66c1e)
Also fixed: @coderabbitai review |
|
🧠 Learnings used✅ Action performedReview finished.
|
…290) POST /api/purchases/{purchaseId}/revoke: Azure returns via armreservations (CalculateRefund + Return two-step), 7-day window. AWS/GCP return 422 and the History UI hides the button for those providers. - DB: migration 000057 adds revocation_window_closes_at, revoked_at, revoked_via, support_case_id columns to purchase_history - RBAC: revoke-own / revoke-any actions, revoke-own granted to all users by default; ownership is via account-access (history rows pre-date created_by_user_id) - Backend: fail-closed nil-auth guard, idempotency via revoked_at IS NULL, Azure CalculateRefund->Return with test-injectable client interfaces - Frontend: canRevokeCompletedRow gate (azure + window open + not yet revoked), Revoke button in History action cell, confirm dialog + toast - All existing mock stores updated with GetPurchaseHistoryByPurchaseID and MarkPurchaseRevoked; auth permission-count tests updated to 12
Three PRs were dispatched in parallel and each independently picked the same "next free" migration number 000057. PR #802 (universal-plans cleanup) took 000057, PR #803 (execute-permissions) renumbered to 000058 in a follow-up, and this PR (#804, revocation) now takes 000059. - 000057_purchase_history_revocation.up.sql -> 000059_* - 000057_purchase_history_revocation.down.sql -> 000059_* - internal/config/store_postgres.go: comment ref "migration 000057" -> "migration 000059" - migration file headers updated Verified 000059 is free on origin/feat/multicloud-web-frontend and no other open PR introduces a clashing migration. Per `project_migration_number_collisions.md`.
… complexity; regenerate permissions on PR #804
) Rebase onto feat/multicloud-web-frontend brought in #907 (group-membership- only authorization, no role field on Session). The revoke handler still gated admin via `session.Role == "admin"`, which no longer compiles since api.Session has no Role field. Replace with the same two-track pattern the sibling authorizeSessionCancel / authorizeSessionApprove already use: - Stateless admin API key short-circuits via `session.UserID == apiKeyAdminUserID` (no DB row exists to resolve permissions from). - Group-based admins fall through to HasPermissionAPI; the {admin, *} wildcard in DefaultAdminPermissions matches revoke-any:purchases there. Drop the dead `Role` field from the revoke test sessions; the admin test now pins the apiKeyAdminUserID short-circuit, and the existing RevokeAny test already covers the group-admin path via HasPermissionAPI. Also fold in the trailing pre-commit fixes that were red on the previous push: - gofmt: realign struct-field padding in TestRevokePurchase_AzureReturnClientError. - go mod tidy: promote armreservations from indirect to direct (the revoke handler imports it directly). Free-cancel window enforcement (AzureRevocationWindowDays + windowClosesAt check) and revoke-call idempotency (early return when record.RevokedAt is already set) are unchanged. Refs #290
PR #941 merged 000059_seed_purchaser_group onto the base, colliding with this PR's 000059_purchase_history_revocation. Renumber to the next free slot vs the updated base (000060; base highest is 000063). Update the header comments in the up/down SQL and the migration-number reference in store_postgres.go to match. No schema change.
Base feat/multicloud-web-frontend now carries 000059_seed_purchaser_group (merged via PR #941) and 000063_purchase_history_monthly_cost_nullable, so the revocation migration must move above the highest base migration to keep the sequence monotonic and avoid collisions on the merge ref. Move both the up and down files to 000064 and update the in-code reference comment in store_postgres.queryPurchaseHistory.
…on DB persist failure Two security fixes from CR findings: 1. checkRevokeOwnAccountAccess: return 403 when CloudAccountID is nil/empty instead of allowing the revoke. Without an account association, ownership cannot be verified for revoke-own callers. Add regression test for this fail-closed contract. 2. revokeAzurePurchase: return error when MarkPurchaseRevoked fails after a successful Azure return. The previous log-and-continue left the DB unmarked, breaking idempotency on retries. The error message notes that the refund was submitted so operators can investigate without re-issuing.
Base merged 000064_relocate_purchaser_group, colliding with this branch's 000064_purchase_history_revocation. Renumber the revocation migration to 000065 (both up/down) and update the in-file migration/rollback header comments. check-migration-conflicts now passes.
…e button works The Revoke button was shipped but dead: RevocationWindowClosesAt was never populated when a completed purchase was written, so the frontend gate canRevokeCompletedRow (which bails on a missing revocation_window_closes_at) hid the button on every real row. - Stamp RevocationWindowClosesAt in the real write path (purchase.savePurchaseHistory): Azure = Timestamp + 7 days (the free-cancel window), nil for AWS/GCP (out of Phase-1 scope), via a new shared config.RevocationWindowClosesAtFor helper + config.AzureRevocationWindowDays constant that is now the single source of truth for the window length. - Make the backend window check (revokeAzurePurchase) read RevocationWindowClosesAt as the source of truth, falling back to recomputing from Timestamp only for legacy rows written before the column was populated. - Reject an order-only ARM path (empty reservationID) in callAzureReturn rather than submitting an empty Return to Azure. - Tests: backend asserts savePurchaseHistory stamps the window for Azure and leaves it nil for AWS/GCP; handler asserts the stamped window drives the deny decision and that an empty reservationID is rejected; FE test asserts the Revoke button shows for a completed Azure row with a populated revocation_window_closes_at and is hidden without it (plus closed-window, already-revoked, non-Azure, and anonymous cases).
) The ActionRevokeOwn doc comment claimed "Own" means created_by_user_id matches the session user, but checkRevokeOwnAccountAccess actually enforces ACCOUNT scope (GetAllowedAccountsAPI), because purchase_history rows pre-date created_by_user_id and have no reliable per-creator attribution. Fix the doc comments to describe the account-scope behavior as implemented; the authz model itself is unchanged. Whether revoke-own should instead be creator-scoped is a product decision tracked in issue #950, noted inline in both the auth constant doc and the handler check.
…oss providers Adds status=scheduled to purchase_executions so the approval step defers the cloud SDK call by purchase_delay_hours. A scheduled execution can be cancelled at $0 via the existing revoke endpoint before the scheduler fires. Two-tier revoke path: - status=scheduled: CancelExecutionAtomic (CAS) transitions to cancelled; no cloud API call, returns 200 with explicit "no cost incurred" message. - status=completed (existing): provider SDK call via purchase_history path. revokePurchase now tries GetExecutionByID first; falls through to the existing purchase_history lookup when no scheduled execution is found. authorizeSessionRevokeExecution mirrors authorizeSessionRevoke but uses CreatedByUserID (not CloudAccountID) for revoke-own scoping. FireScheduledDelayedPurchases (purchase.Manager) drives the scheduler tick: queries GetScheduledExecutionsDue, CAS-transitions scheduled->approved, stamps ApprovedBy="scheduler", calls executeAndFinalize. Registered as TaskFireScheduledPurchases in the Lambda task dispatcher. Also folds in three CodeRabbit findings from PR #804 review f9c66c1e: - Remove unused mockAuth in two AzureCalcRefundClientError/ReturnClientError tests - Add idempotent audit CHECK constraints to migration 000065 (revoked_via, support_case_id, revoked_at/revoked_via pair) - Frontend regression test: legacy blank-status Azure rows stay revocable - analytics/collector_test.go: hook-backed GetPurchaseHistoryByPurchaseID and MarkPurchaseRevoked mocks (no more hardcoded no-ops)
|
Rebased on feat/multicloud-web-frontend. Resolved conflicts in internal/api/handler_purchases.go (kept HEAD's opaque 404 message for UUID enumeration safety, issue #431) and handler_purchases_test.go (kept HEAD's explanatory comment for grantAdmin). |
…an under gocyclo limit revokePurchase -> loadAndRevokePurchaseHistory (handler_purchases_revoke.go) approvePurchase -> approveViaToken (handler_purchases.go) sendPurchaseScheduledEmail -> buildScheduledEmailData (handler_purchases.go) scanExecutionRows -> applyNullTimesToExecution (store_postgres.go) Also applies gofmt alignment fix to internal/analytics/collector_test.go.
|
@coderabbitai review |
✅ Action performedReview finished.
|
…l/mocks (#804 cleanup) Three per-package MockConfigStore definitions (internal/api, internal/purchase, internal/scheduler) were duplicating ~450 LOC each every time a new StoreInterface method was added. Replace all three with a type alias pointing at internal/mocks.MockConfigStore. Changes to internal/mocks/stores.go: - Add Fn-override fields imported from the api and purchase local mocks (GetCloudAccountFn, GetPurchasePlanFn, SetPlanAccountsFn, SavePurchaseExecutionFn, GetPlanAccountsFn, and 6 others) so callers that used those fields keep working without changes. - Add isExpected guards to recommendation-cache and RI-utilization-cache methods and to CancelExecutionAtomic so existing tests that call these without explicit On() expectations don't panic (matches the "opt-in" pattern the per-package mocks used via hasRecExpectation / hasExpectation). - Add 8 interface methods that were missing from the shared mock but present in all per-package variants: GetExecutionsByStatuses, GetPlannedExecutions, GetStaleApprovedExecutions, ListStuckExecutions, GetScheduledExecutionsDue, MarkCollectionStarted, ClearCollectionStarted, StampRIExchangeApprovedBy. - Add compile-time check: var _ config.StoreInterface = (*MockConfigStore)(nil). - Promote GetGlobalConfig and GetPurchasePlan to return sensible defaults when no expectation is registered (matches the api-package behaviour these tests relied on). Per-package files reduced to a single type alias line each. The scheduler test also had stray suppression/Tx method stubs added in a later commit; those are removed because the methods already live on the shared mock. Intentionally left local (incompatible shape or semantics): - internal/analytics/collector_test.go: mockConfigStore (lowercase) -- hook-field only pattern with no testify embedding; analytics-specific subset; different name. - internal/server/test_helpers_test.go: mockConfigStoreForHealth -- all-zero-value stubs for health check tests; distinct type name; no testify embedding. - internal/server/handler_ri_exchange_test.go: mockConfigStoreForExchange -- test- specific struct overriding a handful of methods. - internal/server/handler_coverage_test.go: mockConfigStoreForExchange{Complete, Fail,Stale} -- per-scenario stubs with distinct type names. Net LOC: +274 insertions / -1601 deletions (-1327 net across 4 files).
MockConfigStore consolidation (fc130d4)This commit eliminates the maintenance burden flagged in the PR analysis: every new What changed
Per-package files each reduced to a single type alias line: type MockConfigStore = mocks.MockConfigStoreThe scheduler test also had stray suppression/Tx method stubs added in a later commit; those are removed since the methods already live on the shared mock. Intentionally left local
Net LOC delta
@coderabbitai review |
|
The -1327 net LOC delta is a meaningful reduction in maintenance surface. Triggering a fresh review now. ✏️ Learnings added
🧠 Learnings used✅ Action performedReview finished.
|
Base branch landed 000065_enforce_min_one_admin via PR #921 while this PR was in review. Without a renumber, `golang-migrate` rejects two files sharing the same version number and CI fails on the merge ref. Same collision class as the project_migration_number_collisions memory entry. Updates the body comments and the matching reference in internal/config/types.go.
…oke isn't dead
The pre-fire delay revoke path called CancelExecutionAtomic, whose SQL
guard is `WHERE status IN ('pending','notified')`. A status='scheduled'
row never matches, so the CAS returned zero rows on the happy path --
the handler then surfaced 410 "revocation window has closed" even when
the window was wide open. The user would pay the cloud charge AND see a
"cancelled" attempt in the UI -- the worst possible outcome.
The bug was hidden by mocks defaulting CancelExecutionAtomic to
(true,"cancelled",nil), so every test in the scheduled-revoke suite was
green against the wrong SQL. No pgxmock test exercised the WHERE clause.
Fix: introduce CancelScheduledExecutionAtomic with the correct
`WHERE status = 'scheduled'` guard and switch the handler to it. The two
CAS variants are kept distinct on purpose -- the scheduled-revoke flow
surfaces 410 ("scheduler already fired") on race-loss, while the
pre-purchase cancel flow surfaces 409 ("not pending"). Sharing one method
would conflate the two race outcomes.
Regression test TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS
pins the call to CancelScheduledExecutionAtomic with an Expect and adds
an AssertNotCalled for CancelExecutionAtomic; verified to fail
pre-fix (mock expectation unmet, wrong method called) and pass post-fix.
Touches:
internal/config/{store_postgres,interfaces}.go -- add method + comments
internal/api/handler_purchases_revoke{,_test}.go -- switch call site + reg test
internal/mocks/stores.go -- mock the new method (default happy path)
internal/server/test_helpers_test.go,
internal/analytics/collector_test.go -- satisfy StoreInterface
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the inline Revoke button rendered for every signed-in user regardless of the revoke-any / revoke-own grant. The backend correctly 403s, but the UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete on the same page. The peer predicates (canCancelPendingRow, canApprovePendingRow, canRetryFailedRow) already check canAccess; canRevokeCompletedRow now does too. Verbs match the backend handler one-to-one: - admin or revoke-any:purchases -> always allowed - revoke-own:purchases -> allowed (account-scope enforced server-side) - anything else -> hidden Adds revoke-own / revoke-any to the closed Action union in permissions.ts so a future drift becomes a compile error at the canAccess call site. Adds a regression test that mocks getCurrentUser with an effectivePermissions set lacking revoke-* and asserts the button is hidden; verified to fail without the canAccess gate and pass with it. Also corrects the existing ADMIN_USER fixture to use the real ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was inert because the new canAccess fallback drives off isAdmin() which checks GUID membership.
…-own PR #804 added 'revoke-own:purchases' to USER_PERMS in permissions.generated.ts but missed updating the user-role expected list in __tests__/permissions.test.ts. The test asserts perms.size matches expected.length, so the missing entry surfaced as "Expected 11, received 12" after the addition. This is the same scope as the parent permissions add -- not a separate permission grant, just the test parity update PR #804 should have included alongside the original add.
|
Adversarial review pass on top of the existing commits. Three real defects found and addressed; remaining surfaces verified clean. Findings + fixes (each with a regression test that fails pre-fix, passes post-fix):
Surfaces walked, no real defect:
Verification: full @coderabbitai review |
|
B1 is a clean catch too — H1 renumber (000066) and the Triggering a fresh review now. 🐇 🔍 ✏️ Learnings added
⛔ Learnings removed🧠 Learnings used✅ Action performedReview finished.
|
Summary
Implements in-app purchase revocation with two complementary layers:
completed, the existing AzureCalculateRefund+ReturnSDK call still applies. AWS/GCP return 422 here (no public cancel API).Closes #290.
The Gmail-style pre-fire delay (primary path)
PurchaseDelayHours[0, 168]Flow
PurchaseDelayHours = 0preserves the legacy immediate-execute behavior — opt-in for users who don't want any delay.WHERE status='scheduled' AND scheduled_execution_at <= NOW(). Uses an owner-token compare-and-clear pattern so two overlapping ticks can't fire the same row twice.Why this is the right shape
The original #804 design called the provider cancel API after the SDK purchase had already settled. That works on Azure (7-day refund window) but not on AWS / GCP, which have no public cancel API. Deferring our own SDK call sidesteps the asymmetry entirely — for the common case ("I just clicked approve and changed my mind"), no provider cooperation is needed.
Provider-cancel API fallback (the original #804 design)
Still wired up for rows that pass
scheduled_execution_atwithout being revoked. Useful when the user wants delay=0, or returns a week later.CalculateRefund+ReturnviaarmreservationsSDK, 7-day window. Button shown only for Azure rows within the window.Schema (migration 000065)
ALTER TABLE purchase_history ADD COLUMN IF NOT EXISTS(idempotent):revocation_window_closes_at TIMESTAMPTZ— set at write-path per provider policy (pre-fire delay) OR per provider's published window (fallback).scheduled_execution_at TIMESTAMPTZ— when the deferred SDK call should fire (pre-fire delay only).revoked_at TIMESTAMPTZ,revoked_via VARCHAR(32)— stamped on revoke.support_case_id TEXT— reserved for Phase 2 AWS Support case (feat(email): post-execution notification email with one-click revocation link valid for the AWS cancel window #291).CHECKconstraints enforce audit consistency:revoked_via ∈ {known values, …}or NULL.support_case_idnon-null only whenrevoked_via = 'support-case'.revoked_atandrevoked_viaare jointly null or jointly non-null.Backend
POST /api/purchases/{id}/revokeroute atAuthUserlevel.revoke-own:purchases/revoke-any:purchases.revoke-owngranted by default to all users (mirrorsapprove-own). Ownership uses account-access (GetAllowedAccountsAPI) because history rows pre-datecreated_by_user_id.revoked_at IS NULLguard inMarkPurchaseRevoked. Already-revoked returnsstatus: already_revokedwithout re-firing.internal/scheduler/scheduler.gofor scheduled-purchase fire. Owner-token CAS protects against overlapping ticks.Frontend
canRevokeCompletedRow: provider-agnostic forscheduledstatus (pre-fire window); Azure-only forcompletedstatus (provider window). Both gated on session + not yet revoked.escapeHtmlonpurchase_id.api.revokePurchase()+ success toast + history refresh.scheduled(e.g. "Scheduled to execute in 47h — revoke link sent to email").Tests
scheduled_execution_at <= nowfires SDK, transitions to completed.parseAzureReservationIDstable tests,authorizeSessionRevokeRBAC matrix.Test plan
go test ./...— all packages greennpm testinfrontend/— all suites greenPurchaseDelayHours = 1h, verify email arrives immediately, status isscheduled. Click revoke from email → status flips tocancelled, no SDK call.PurchaseDelayHours = 0, verify legacy immediate-execute behavior unchanged.scheduled_execution_at; verify scheduler fires SDK, status →completed, confirmation email arrives without revoke link.Related
support:CreateCasedrafter for post-window AWS revocation): tracked separately at feat(email): post-execution notification email with one-click revocation link valid for the AWS cancel window #291.