Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,11 @@ export {
resumePlannedPurchase,
runPlannedPurchase,
deletePlannedPurchase,
createPlannedPurchases
createPlannedPurchases,
createMarketplaceListing,
cancelMarketplaceListing
} from './purchases';
export type { RetryPurchaseResult } from './purchases';
export type { RetryPurchaseResult, MarketplacePriceTier, MarketplaceListResult } from './purchases';

// Re-export users functions
export {
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/api/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,48 @@ export async function createPlannedPurchases(planId: string, count: number, star
body: JSON.stringify({ count, start_date: startDate })
});
}

// ── RI Marketplace (issue #292) ───────────────────────────────────────────────

export interface MarketplacePriceTier {
/** Number of months remaining this price tier covers. */
term_months: number;
/** USD list price per unit for this tier. */
price: number;
}

export interface MarketplaceListResult {
listing_id: string;
listing_state: string;
price_schedule: MarketplacePriceTier[];
aws_fee_percent: number;
note?: string;
}

/**
* Create an AWS RI Marketplace listing for a Standard Reserved Instance.
* purchaseId is the purchase_history.purchase_id (AWS ReservedInstancesId).
* priceSchedule is optional: when omitted the backend computes a default.
*/
export async function createMarketplaceListing(
purchaseId: string,
priceSchedule?: MarketplacePriceTier[],
): Promise<MarketplaceListResult> {
const body = priceSchedule && priceSchedule.length > 0
? JSON.stringify({ price_schedule: priceSchedule })
: undefined;
return apiRequest<MarketplaceListResult>(`/purchases/${purchaseId}/marketplace-list`, {
method: 'POST',
...(body ? { body } : {}),
});
}

/**
* Cancel an active AWS RI Marketplace listing.
*/
export async function cancelMarketplaceListing(purchaseId: string): Promise<{ listing_id: string; listing_state: string }> {
return apiRequest<{ listing_id: string; listing_state: string }>(
`/purchases/${purchaseId}/marketplace-cancel`,
{ method: 'POST' },
);
}
179 changes: 176 additions & 3 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,49 @@ function retryThresholdReached(p: HistoryPurchase): boolean {
return (p.retry_attempt_n ?? 0) >= RETRY_THRESHOLD;
}

// canSellOnMarketplace returns true when the current session is permitted
// to list the given completed history row on the AWS RI Marketplace
// (issue #292). UX gate only -- the backend authorizeSessionSell in
// internal/api/handler_marketplace.go remains the security boundary; a
// false-positive here surfaces as a 403 toast on click.
//
// Conditions:
// * row must be a completed purchase (status "completed" or absent);
// * offering_class must be "standard";
// * no active listing already (listing_state != "active"); and
// * admin, or non-admin user (sell-own covers their own accounts --
// we can't efficiently check per-account ownership client-side, so
// we show the button for all non-admin users and let the backend 403
// when the account is out of scope).
function canSellOnMarketplace(p: HistoryPurchase): boolean {
const status = normalizeStatus(p).toLowerCase();
if (status !== 'completed') return false;
if ((p.offering_class || '').toLowerCase() !== 'standard') return false;
if ((p.listing_state || '').toLowerCase() === 'active') return false;
const user = getCurrentUser();
if (!user) return false;
// Guard against listing a matured RI: compute remaining months from the
// purchase timestamp and the total term. term is in months; timestamp is
// the purchase date. We require at least 1 full month remaining.
const termMonths = typeof p.term === 'number' ? p.term : Number(p.term) || 0;
if (termMonths <= 0) return false;
const purchaseMs = new Date(p.timestamp).getTime();
if (!Number.isFinite(purchaseMs)) return false;
const elapsedMonths = (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375);
const remainingMonths = termMonths - elapsedMonths;
if (remainingMonths < 1) return false;
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// canCancelMarketplaceListing returns true when there is an active listing
// that the current session can cancel.
function canCancelMarketplaceListing(p: HistoryPurchase): boolean {
if ((p.listing_state || '').toLowerCase() !== 'active') return false;
const user = getCurrentUser();
if (!user) return false;
return true;
}

// shortExecID renders the first 8 chars of a UUID so inline lineage
// links ("Retried as #abc12345") stay readable in the table cell. The
// full ID is preserved in the data-history-status attribute so the
Expand All @@ -493,7 +536,9 @@ function sameRowActions(btn: HTMLButtonElement): HTMLButtonElement[] {
const cell = btn.closest('td') || btn.parentElement;
if (!cell) return [btn];
return Array.from(
cell.querySelectorAll<HTMLButtonElement>('.history-approve-btn, .history-cancel-btn'),
cell.querySelectorAll<HTMLButtonElement>(
'.history-approve-btn, .history-cancel-btn, .history-marketplace-sell-btn, .history-marketplace-cancel-btn',
),
);
}

Expand Down Expand Up @@ -572,8 +617,21 @@ function renderActionCell(p: HistoryPurchase): string {
// see "this is a retry" provenance.
lineage.push(`<span class="history-retry-link history-retry-of" title="This row is retry #${p.retry_attempt_n} in its chain">↻ Retry #${p.retry_attempt_n}</span>`);
}
if (lineage.length > 0) {
return lineage.join(' ');
// Completed Standard RI rows: offer Sell on Marketplace (issue #292).
// Build the trailing action buttons first so they compose with lineage
// links — a retry-descendant can also have an active listing that needs
// a Cancel button.
const trailingActions: string[] = [];
if (p.purchase_id) {
if (canCancelMarketplaceListing(p)) {
trailingActions.push(`<button type="button" class="btn-link history-marketplace-cancel-btn" data-marketplace-cancel-id="${escapeHtml(p.purchase_id)}">Cancel listing ${escapeHtml(p.listing_id || '')}</button>`);
} else if (canSellOnMarketplace(p)) {
trailingActions.push(`<button type="button" class="btn-link history-marketplace-sell-btn" data-marketplace-sell-id="${escapeHtml(p.purchase_id)}">Sell on Marketplace</button>`);
}
}

if (lineage.length > 0 || trailingActions.length > 0) {
return [...lineage, ...trailingActions].join(' ');
}

return escapeHtml(p.plan_name || '-');
Expand Down Expand Up @@ -863,6 +921,121 @@ function wireRowActionHandlers(container: HTMLElement): void {
}
});
});

// Wire Sell on Marketplace button (issue #292).
// Flow: pricing/schedule modal (RI summary + default price + 12% fee) →
// user confirms → createMarketplaceListing. We never skip the pricing
// modal (CR finding: going straight from confirmDialog to the API call
// denies the user informed consent about the price and fee).
container.querySelectorAll<HTMLButtonElement>('.history-marketplace-sell-btn[data-marketplace-sell-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset['marketplaceSellId'];
if (!id) return;

// Look up the purchase record so we can show a meaningful price summary.
const purchase = lastPurchases.find(p => p.purchase_id === id);

// Build a pricing modal body with RI summary and fee breakdown.
const bodyEl = document.createElement('div');
bodyEl.className = 'marketplace-pricing-modal-body';

if (purchase) {
const termMonths = typeof purchase.term === 'number' ? purchase.term : Number(purchase.term) || 0;
const purchaseMs = new Date(purchase.timestamp).getTime();
const elapsedMonths = Number.isFinite(purchaseMs)
? (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375)
: 0;
const remainingMonths = Math.max(0, Math.round(termMonths - elapsedMonths));
const upfront = purchase.upfront_cost ?? 0;
const monthly = purchase.monthly_cost ?? 0;
const totalValue = upfront + monthly * remainingMonths;
const listPrice = totalValue * 0.95;
const netProceeds = listPrice * 0.88;

const summaryEl = document.createElement('dl');
summaryEl.className = 'marketplace-pricing-summary';
const addRow = (label: string, value: string): void => {
const dt = document.createElement('dt');
dt.textContent = label;
const dd = document.createElement('dd');
dd.textContent = value;
summaryEl.appendChild(dt);
summaryEl.appendChild(dd);
};
addRow('RI ID', id);
addRow('Region', purchase.region || '-');
addRow('Resource type', purchase.resource_type || '-');
addRow('Remaining term', remainingMonths === 1 ? '1 month' : `${remainingMonths} months`);
addRow('Default list price', formatCurrency(listPrice));
addRow('AWS fee (12%)', formatCurrency(listPrice * 0.12));
addRow('Estimated net proceeds', formatCurrency(netProceeds));
bodyEl.appendChild(summaryEl);
}

const noteEl = document.createElement('p');
noteEl.className = 'marketplace-pricing-note';
noteEl.textContent = 'AWS charges a 12% transaction fee on proceeds. The default schedule prices the listing at 5% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.';
bodyEl.appendChild(noteEl);

const ok = await confirmDialog({
title: 'List this RI on the AWS Marketplace?',
body: bodyEl,
confirmLabel: 'Confirm listing',
destructive: false,
});
if (!ok) return;

const rowActions = sameRowActions(btn);
rowActions.forEach(b => { b.disabled = true; });
try {
await api.createMarketplaceListing(id);
} catch (sellError) {
console.error('Failed to list RI on Marketplace:', sellError);
const err = sellError as Error;
showToast({ message: `Failed to list on Marketplace: ${err.message || 'unknown error'}`, kind: 'error' });
rowActions.forEach(b => { b.disabled = false; });
return;
}
showToast({ message: 'RI listed on Marketplace successfully', kind: 'success', timeout: 5_000 });
try {
await loadHistory();
} catch (reloadError) {
console.error('Failed to reload history after Marketplace listing:', reloadError);
}
});
});

// Wire Cancel listing button (issue #292)
container.querySelectorAll<HTMLButtonElement>('.history-marketplace-cancel-btn[data-marketplace-cancel-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset['marketplaceCancelId'];
if (!id) return;
const ok = await confirmDialog({
title: 'Cancel this Marketplace listing?',
body: 'This will remove the listing from the AWS Marketplace. Any existing buyer negotiations will be cancelled. You can relist the RI at any time.',
confirmLabel: 'Cancel listing',
destructive: true,
});
if (!ok) return;
const rowActions = sameRowActions(btn);
rowActions.forEach(b => { b.disabled = true; });
try {
await api.cancelMarketplaceListing(id);
} catch (cancelError) {
console.error('Failed to cancel Marketplace listing:', cancelError);
const err = cancelError as Error;
showToast({ message: `Failed to cancel listing: ${err.message || 'unknown error'}`, kind: 'error' });
rowActions.forEach(b => { b.disabled = false; });
return;
}
showToast({ message: 'Marketplace listing cancelled', kind: 'success', timeout: 5_000 });
try {
await loadHistory();
} catch (reloadError) {
console.error('Failed to reload history after Marketplace cancel:', reloadError);
}
});
});
}

// isPendingRow returns true when a history row represents a purchase
Expand Down
1 change: 1 addition & 0 deletions frontend/src/permissions.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const USER_PERMS: ReadonlySet<string> = new Set([
'create:plans',
'delete:plans',
'retry-own:purchases',
'sell-own:purchases',
'update:plans',
'update:purchases',
'view:history',
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ export interface HistoryPurchase {
// absent otherwise. Replaces the Retry button entirely — there is
// no actionable retry from a persistent misconfig.
ops_hint?: string;
// OfferingClass is "standard" or "convertible" for EC2 RIs. Absent
// on non-EC2 rows and on rows written before migration 000060.
// The "Sell on Marketplace" button only renders when this equals
// "standard" (issue #292).
offering_class?: string;
// ListingID is the AWS ReservedInstancesListingId. Non-empty when an
// active or recently-closed marketplace listing exists (issue #292).
listing_id?: string;
// ListingState is the AWS marketplace listing state: "active",
// "cancelled", or "closed". Empty when not listed.
listing_state?: string;
}

// Savings Analytics types
Expand Down
7 changes: 7 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string,
return nil
}

func (m *mockConfigStore) GetPurchaseHistoryByPurchaseID(_ context.Context, _ string) (*config.PurchaseHistoryRecord, error) {
return nil, nil
}
func (m *mockConfigStore) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error {
return nil
}

// TestNewCollector tests the NewCollector function
func TestNewCollector(t *testing.T) {
t.Run("returns error when analytics store is nil", func(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ type Handler struct {
// armreservations-backed client.
azureExchangeFactory func(subscriptionID string) azureExchangeClient

// Optional marketplace EC2 client factory injected by tests. When nil
// (the production default), buildMarketplaceEC2Client uses
// awsprovider.NewEC2ClientDirect.
marketplaceEC2Factory func(aws.Config) marketplaceEC2Client

// Optional account-resolver injection point used by the reshape
// handler integration test. When nil (the production default), the
// handler calls h.resolveAWSCloudAccountID which in turn invokes
Expand Down
Loading
Loading