Skip to content

feat(purchases): in-app revocation flow — direct API for free-cancel window + AWS Support case drafter for week window #290

@cristim

Description

@cristim

Background

When a purchase executes, the user's only safety net today is the on-AWS-console cancellation procedure — and the dashboard never tells them about it. After the upfront charge fires, there's no in-app affordance to revoke the commitment within the AWS-allowed window. The user has to know:

  1. That AWS has a free-cancellation window for Reserved Instances (currently the first ~24h after purchase, per AWS docs — call it the "free cancel window") and a separate longer window where revocation is possible only via an AWS Support case.
  2. How to navigate to the AWS Console → Account & Billing → Refund flow.
  3. Which support-case template to file when the free window has passed.

This is recoverability information that belongs in the dashboard, not in the user's tribal AWS knowledge.

What needs to happen

In-app revocation surface

On any executed purchase row in the History view, while it's still within the AWS revocation window:

  • Render an "Revoke purchase" button (mirroring the Cancel-button shape from PR feat(history): inline Cancel button + cancel-any/own RBAC verbs #145).

  • The button's behavior depends on which window the row is in:

    Phase 1 — within the free-cancel window (≤24h post-execution, AWS source-of-truth):

    • One-click revocation. Backend hits the AWS RI cancellation API (ec2:ModifyReservedInstances with the cancel-on-modify path; for Savings Plans the equivalent is savingsplans:DeleteQueuedSavingsPlan for queued + the regular cancel API for active). Result is a real refund.
    • Audit field revoked_via = "free-window-api".

    Phase 2 — past free-window, within the support-case window (≤7d post-execution, or whatever the current AWS policy is — verify when implementing):

    • The button opens a modal that drafts an AWS Support case with the right subject/body/category pre-filled. The user reviews + submits via the AWS Support API (support:CreateCase) directly from the dashboard. API-only — no console deep-link fallback: the case must be filed through the same authorized backend path that does the rest of the dashboard's cloud calls, so the audit trail (support_case_id, revoked_via) is captured server-side rather than relying on the user manually pasting back a case ID after a console round-trip.
    • Audit field revoked_via = "support-case-api" and support_case_id populated from the CreateCase response.
    • The dashboard polls case status via support:DescribeCases (or surfaces a manual "mark resolved" affordance) — AWS Support case resolution is human-driven, not API-instantaneous.
    • IAM: requires the AWS Business or Enterprise Support plan for the target account (the AWS Support API is gated to those tiers). Detect the plan tier on the runtime side; if the account is on Basic/Developer Support, render Phase 2 as a closed-window message rather than offering a non-functional button — support:CreateCase would 401 / SubscriptionRequiredException otherwise.

    Phase 3 — past the support-case window:

    • Button replaced by a tooltip: "Revocation window closed. Contact AWS Support if you believe a refund is warranted."
    • No drafted case; rare cases at this point require a custom narrative.

Backend

  • New table or columns on purchase_executions: revocation_window_closes_at (computed at execute-time from AWS policy + the rec type), revoked_at, revoked_via (enum), support_case_id (nullable text).
  • Permission: revoke-any:purchases and revoke-own:purchases mirroring the cancel-{any,own} shape. Default: RoleAdmin → revoke-any; RoleUser → revoke-own is conservative (revocation is reversal of a finance action, not a request).
  • AWS Support SDK client must be configured for us-east-1 regardless of the rec's region — the AWS Support API is global but routes only through us-east-1. Mismatched region produces an EndpointError.

IAM impact (customer-facing — non-trivial)

The actions this issue requires are NOT in the cudly-aws-runtime onboarding bundle today. Two layers need updating:

  1. CUDly's own runtime SA (the deploy SA managed by terraform/modules/compute/aws/...): straightforward addition narrowly scoped — ec2:ModifyReservedInstances, ec2:CancelReservedInstancesListing (if applicable), savingsplans:DeleteQueuedSavingsPlan, support:CreateCase, support:DescribeCases, support:DescribeServices. Per project CLAUDE.md's runtime/bootstrap split rule, these go into the runtime module — NOT the bootstrap ci-cd-permissions/.

  2. The per-customer onboarding IAM bundle (terraform/modules/onboarding/aws/... or wherever the customer-facing cross-account role definition lives — confirm at implementation time): customers attach this role to grant CUDly access to their account. The bundle's policy needs the same set of actions. This is a breaking-shape change for existing customers: their current trust policy doesn't include the new actions, so the revocation flow will silently 403 on accounts attached pre-this-PR until they re-apply the updated bundle.

Migration plan needed:

  • New customers auto-get the expanded bundle.
  • Existing customers: a banner in the dashboard ("Action required: re-apply onboarding bundle to enable purchase revocation"), OR a one-shot terraform apply script the customer runs to extend their existing role policy in place. Cannot be done autonomously by CUDly — the customer's account is the one that owns the IAM role definition.

This is the same migration shape PR #128 (External ID lifecycle hardening) used — there's a precedent for "you'll need to re-apply the bundle" affordances in this repo.

Support plan gating

support:CreateCase requires AWS Business or Enterprise Support for the target account; Basic/Developer plans return SubscriptionRequiredException. Detect the plan via support:DescribeServices (which returns the same error if not subscribed) before showing Phase 2 controls. If the customer is on Basic/Developer, fall back to a "your account doesn't have AWS Support API access; cancellation must be filed manually" message rather than offering a button that 401s.

Email notification

After execution, the post-execution notification email (sibling issue #291) embeds the same revocation control via a token-authed /api/purchases/{id}/revoke?token=… route — analogous to the approve/cancel token routes. The route runs the same phase-aware backend path the in-app button does:

  • Phase 1 → fires the AWS API and refunds.
  • Phase 2 → opens the support-case-drafter modal in the dashboard (link redirects authenticated user there) OR, if the recipient prefers email-only flow, the email itself can carry a "submit support case" CTA that the backend processes server-side via support:CreateCase. Either way the case is filed via API, never via a console redirect.

Token expires when the AWS support-case window closes (Phase 3 in-app behavior maps to a "this link has expired" page on the email-link path).

Verifying the AWS policy specifics

Before implementing, confirm the current policy in AWS docs (it has shifted):

  • AWS RI free-cancel window: as of writing, RIs purchased for the first time can be modified or cancelled within a limited period via the console; standalone "cancel" is via the support flow. Verify against the live AWS docs at implementation time and document the policy in code comments alongside revocation_window_closes_at.
  • Savings Plans: queued SPs (State == queued) can be cancelled via API; active SPs are not directly revocable — revocation requires AWS Support case. Verify.
  • The "within a week" framing in the issue ask matches AWS Support's typical refund-grace policy but is not a published API contract; treat it as "support-case territory" rather than a hard window.

Acceptance

  • A purchase row within the free-cancel window shows a Revoke button that, on click, fires the AWS API and the row transitions to revoked with the API response logged.
  • A purchase row past the free-cancel window shows a Revoke button that drafts an AWS Support case via support:CreateCase with the rec details pre-filled — no console deep-link fallback; the case is filed through the same authorized backend path as the rest of the dashboard's cloud calls.
  • The post-execution notification email (sibling feat(email): post-execution notification email with one-click revocation link valid for the AWS cancel window #291) carries an equivalent token-authed /api/purchases/{id}/revoke?token=… link that runs through the same phase-aware backend logic — so a recipient who never opens the dashboard can still revoke from email.
  • Past the support-case window, the button is replaced by a closed-window tooltip; the email link returns a "this link has expired" page.
  • AWS Support plan tier detection: accounts on Basic/Developer Support render Phase 2 as a closed-window message rather than a 401-prone button.
  • A test seeds an executed row + asserts the correct phase rendering at each point in time + asserts the email-link path runs through the same backend logic.
  • IAM additions ship in the per-cloud runtime module, NOT the bootstrap module.
  • The per-customer onboarding bundle is updated with the new actions, and an in-dashboard "re-apply onboarding bundle" affordance surfaces for accounts whose role doesn't yet have the new permissions.

Severity / priority

p2 / severity/medium / impact/few — recoverability is a tail-event feature, but when it matters, today's user has zero in-app recovery path. The IAM bundle expansion + customer migration concern is the main reason this is an effort/m rather than effort/s — net-new AWS-side cancellation calls + a support-case API caller + a phase-aware UI + a plan-tier detector + a customer onboarding-bundle migration.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions