Skip to content
Merged
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
88 changes: 88 additions & 0 deletions frontend/src/__tests__/commitmentSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { buildCommitmentSummary, commitmentTotals, paymentLabel, withIntro, type CommitmentLine } from '../commitmentSummary';

const line = (over: Partial<CommitmentLine> = {}): CommitmentLine => ({
service: 'ec2',
resourceType: 'm5.large',
count: 2,
term: 3,
payment: 'no-upfront',
upfront: 0,
monthly: 100,
...over,
});

describe('commitmentTotals', () => {
it('sums upfront, monthly and the term total (3 years = 36 months)', () => {
const t = commitmentTotals([line({ upfront: 500, monthly: 100 }), line({ upfront: 0, monthly: 50, term: 1 })]);
expect(t.upfront).toBe(500);
expect(t.monthly).toBe(150);
expect(t.termTotal).toBe(500 + 100 * 36 + 50 * 12);
});

it('is null for monthly and term total when any line has no monthly cost', () => {
const t = commitmentTotals([line(), line({ monthly: null, upfront: 10 })]);
expect(t.upfront).toBe(10);
expect(t.monthly).toBeNull();
expect(t.termTotal).toBeNull();
});
});

describe('summary options and hints', () => {
it('relabels the upfront stat for the approval path', () => {
const text = buildCommitmentSummary([line({ upfront: 8400 })], { upfrontLabel: 'Upfront on approval' }).textContent;
expect(text).toContain('Upfront on approval$8,400.00');
expect(text).not.toContain('Charged today');
});

it('treats the Azure "upfront" spelling as all upfront (no monthly-fee hint)', () => {
const text = buildCommitmentSummary([line({ monthly: null, payment: 'upfront' })]).textContent;
expect(text).toContain('MonthlyNot reported');
expect(text).not.toContain('fees apply');
expect(text).toContain('All upfront');
});

it('hints that monthly fees apply for non all-upfront lines with no monthly figure', () => {
expect(buildCommitmentSummary([line({ monthly: null, payment: 'partial-upfront' })]).textContent)
.toContain('MonthlyNot reported (monthly fees apply)');
expect(buildCommitmentSummary([line({ monthly: null, payment: 'all-upfront' })]).textContent)
.toContain('MonthlyNot reported');
expect(buildCommitmentSummary([line({ monthly: null, payment: 'all-upfront' })]).textContent)
.not.toContain('fees apply');
});
});

describe('paymentLabel', () => {
it.each([
['all-upfront', 'All upfront'],
['partial_upfront', 'Partial upfront'],
['no-upfront', 'No upfront'],
['upfront', 'All upfront'],
['monthly', 'Monthly'],
['custom', 'custom'],
])('%s -> %s', (raw, label) => expect(paymentLabel(raw)).toBe(label));
});

describe('buildCommitmentSummary', () => {
it('shows charged today, monthly and total over term', () => {
const el = buildCommitmentSummary([line({ upfront: 1200, monthly: 10, term: 1, account: 'prod' })]);
const text = el.textContent ?? '';
expect(text).toContain('Charged today$1,200.00');
expect(text).toContain('Monthly$10.00');
expect(text).toContain('Total over term$1,320.00');
expect(text).toContain('2 × ec2 m5.large · 1 Year, No upfront · prod');
});

it('says so when the monthly cost is not reported, and truncates long lists', () => {
const el = buildCommitmentSummary(Array.from({ length: 7 }, () => line({ monthly: null })));
expect(el.textContent).toContain('MonthlyNot reported');
expect(el.textContent).toContain('Total over termNot available');
expect(el.querySelectorAll('li')).toHaveLength(6);
expect(el.textContent).toContain('and 2 more');
});

it('withIntro puts the sentence before the summary', () => {
const body = withIntro('Intro.', buildCommitmentSummary([line()]));
expect(body.firstElementChild?.textContent).toBe('Intro.');
expect(body.querySelector('.commitment-summary')).not.toBeNull();
});
});
28 changes: 28 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,34 @@ describe('Plans Module', () => {
expect(api.runPlannedPurchase).not.toHaveBeenCalled();
});

test('run confirm names the step and shows the amount charged today', async () => {
mockConfirmDialog.mockClear();
mockConfirmDialog.mockResolvedValue(false);

(document.querySelector('[data-action="run"]') as HTMLButtonElement).click();
await new Promise(resolve => setTimeout(resolve, 50));

const opts = mockConfirmDialog.mock.calls[0]![0] as { title: string; body: HTMLElement };
expect(opts.title).toBe('Run step 1/4 of "Test Plan" now?');
expect(opts.body.textContent).toMatch(/Charged today\$1,?000/);
expect(opts.body.textContent).toContain('5 \u00d7 ec2 t3.medium');
});

test('a second click while the run confirm is open does not stack a second dialog', async () => {
mockConfirmDialog.mockClear();
let resolveDialog: (v: boolean) => void = () => undefined;
mockConfirmDialog.mockReturnValue(new Promise<boolean>(r => { resolveDialog = r; }));

const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
runBtn.click();
runBtn.click();
await new Promise(resolve => setTimeout(resolve, 20));
expect(mockConfirmDialog).toHaveBeenCalledTimes(1);

resolveDialog(false);
await new Promise(resolve => setTimeout(resolve, 20));
});

test('pause action pauses purchase', async () => {
(api.pausePlannedPurchase as jest.Mock).mockResolvedValue({});
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,20 @@ describe('Issue #251: Execute Now makes no cancellation promise', () => {
expect(api.executePurchase).not.toHaveBeenCalled();
});

test('approval confirm labels the amount as upfront on approval, not charged today', async () => {
const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 };
(state.getRecommendations as jest.Mock).mockReturnValue([rec]);
(confirmDialog as jest.Mock).mockClear();
await openPurchaseModal([rec]);
(document.getElementById('execute-purchase-btn') as HTMLButtonElement).click();
await flush();
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { body: HTMLElement };
const text = opts.body.textContent ?? '';
expect(text).toContain('Nothing is charged until the approver');
expect(text).toContain(`Upfront on approval${formatCurrency(rec.upfront_cost, '$', 2)}`);
expect(text).not.toContain('Charged today');
});

test('direct-execute confirm dialog makes no cancellation promise', async () => {
const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 };
(state.getRecommendations as jest.Mock).mockReturnValue([rec]);
Expand All @@ -308,9 +322,12 @@ describe('Issue #251: Execute Now makes no cancellation promise', () => {
(document.getElementById('execute-purchase-btn') as HTMLButtonElement).click();
await flush();
expect(confirmDialog).toHaveBeenCalledTimes(1);
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { title: string; body: string };
expect(opts.body).toMatch(/charge the full upfront amount immediately/);
expect(`${opts.title} ${opts.body}`).not.toMatch(/AWS|cancell|24 hours/i);
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { title: string; body: HTMLElement };
const bodyText = opts.body.textContent ?? '';
expect(bodyText).toMatch(/charges the amount shown under "Charged today" immediately/);
expect(bodyText).toContain('Charged today');
expect(bodyText).toContain(formatCurrency(rec.upfront_cost, '$', 2));
expect(`${opts.title} ${bodyText}`).not.toMatch(/AWS|cancell|24 hours/i);
});
});

Expand Down
33 changes: 33 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@ describe('openExchangeModal', () => {
expect(execReq.region).toBe('us-west-2');
});

it('names instance types and the hourly price change in the execute confirm', async () => {
const mockGetQuote = api.getExchangeQuote as jest.Mock;
mockGetQuote.mockResolvedValueOnce({
IsValidExchange: true,
ValidationFailureReason: '',
CurrencyCode: 'USD',
PaymentDueRaw: '12.50',
SourceHourlyPriceRaw: '0.1000',
SourceRemainingUpfrontRaw: '',
SourceRemainingTotalRaw: '',
TargetHourlyPriceRaw: '0.1250',
TargetRemainingUpfrontRaw: '',
TargetRemainingTotalRaw: '',
Region: 'us-west-2',
});
const offeringUUID = '4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91';
openExchangeModal('ri-abc', 3, 'm5.large', [
{ instance_type: 'm5.large', offering_id: offeringUUID, effective_monthly_cost: 42.5 },
]);
Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Get Quote')?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Execute Exchange')?.click();

const text = document.querySelector('.modal-confirm-body')?.textContent ?? '';
expect(text).toContain('Pay now: USD 12.50');
expect(text).toContain('3 \u00d7 m5.large');
// The exact offering id stays visible next to the instance type.
expect(text).toContain(`3 \u00d7 m5.large (offering ${offeringUUID})`);
expect(text).toContain('USD 0.1000 \u2192 0.1250 (+0.0250 USD/hr, about +18.25 USD/month)');
expect(document.activeElement?.textContent).toBe('Cancel');
document.querySelector<HTMLButtonElement>('.modal-confirm-close')?.click();
});

it('invalidates a fetched quote when the count changes, hiding Execute (issue #244)', async () => {
const mockGetQuote = api.getExchangeQuote as jest.Mock;
mockGetQuote.mockResolvedValueOnce({
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as api from './api';
import * as state from './state';
import { showLoginModal, showAdminSetupModal, showResetPasswordModal, updateUserUI } from './auth';
import { loadDashboard, setupDashboardHandlers } from './dashboard';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, type FanOutBucket } from './recommendations';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, getAccountName, type FanOutBucket } from './recommendations';
import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation';
import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans';
import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings';
Expand All @@ -18,6 +18,7 @@ import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
import { formatPaymentAdjustmentNotice } from './commitmentOptions';
import { confirmDialog } from './confirmDialog';
import { buildCommitmentSummary, withIntro } from './commitmentSummary';
import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
Expand Down Expand Up @@ -398,16 +399,31 @@ export async function handleExecutePurchase(): Promise<void> {
// - Approval path: low-friction, non-destructive.
// - Direct-execute path: red destructive dialog with cost callout and
// immediate-charge callout; no cancellation promise (issue #251).
const summary = buildCommitmentSummary(
recsWithPayment.map((r) => ({
service: r.service,
provider: r.provider,
region: r.region,
resourceType: r.resource_type,
count: r.count,
term: r.term,
payment: r.payment,
upfront: r.upfront_cost,
monthly: r.monthly_cost ?? null,
...(r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {}),
})),
isDirect ? {} : { upfrontLabel: 'Upfront on approval' },
);
const ok = isDirect
? await confirmDialog({
title: `Execute ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} now?`,
body: 'This will charge the full upfront amount immediately. This bypasses the approval step.',
body: withIntro('This charges the amount shown under "Charged today" immediately and bypasses the approval step. The monthly amount, if any, is billed for the whole term.', summary),
confirmLabel: 'Execute Purchase Now',
destructive: true,
})
: await confirmDialog({
title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
body: withIntro('This emails an approval request to the configured approver. Nothing is charged until the approver clicks the link in that email.', summary),
confirmLabel: 'Send for approval',
destructive: false,
});
Expand Down
133 changes: 133 additions & 0 deletions frontend/src/commitmentSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Shared "what exactly will be bought" block for money-moving confirm
* dialogs (Execute now, Run now, RI exchange). Pure DOM builder plus the
* totals helper, so every confirm states the same three numbers: what is
* charged today, what recurs monthly, and the total over the term.
*/

import { formatCurrency, formatTerm } from './utils';

export interface CommitmentLine {
service: string;
provider?: string;
region?: string;
resourceType: string;
count: number;
/** Term in years. */
term: number;
payment: string;
upfront: number;
/** null when the provider did not report a recurring breakdown. */
monthly: number | null;
/** Resolved display name of the account, when known. */
account?: string;
}

export interface CommitmentTotals {
upfront: number;
/** null when any line has no reported monthly cost. */
monthly: number | null;
/** upfront + monthly x 12 x term; null when monthly is null. */
termTotal: number | null;
}

const MAX_LISTED_LINES = 5;

export function commitmentTotals(lines: readonly CommitmentLine[]): CommitmentTotals {
let upfront = 0;
let monthly: number | null = 0;
let termTotal: number | null = 0;
for (const l of lines) {
upfront += l.upfront;
if (l.monthly === null || monthly === null || termTotal === null) {
monthly = null;
termTotal = null;
continue;
}
monthly += l.monthly;
termTotal += l.upfront + l.monthly * 12 * l.term;
}
return { upfront, monthly, termTotal };
}

export function paymentLabel(payment: string): string {
switch (payment.replace(/_/g, '-')) {
case 'upfront': // Azure spelling of all-upfront
case 'all-upfront': return 'All upfront';
case 'partial-upfront': return 'Partial upfront';
case 'no-upfront': return 'No upfront';
case 'monthly': return 'Monthly';
default: return payment;
}
}

function stat(label: string, value: string): HTMLElement {
const el = document.createElement('div');
el.className = 'commitment-summary-stat';
const l = document.createElement('span');
l.className = 'commitment-summary-label';
l.textContent = label;
const v = document.createElement('strong');
v.className = 'commitment-summary-value';
v.textContent = value;
el.append(l, v);
return el;
}

// A partial or no-upfront commitment bills monthly; saying only "not
// reported" would read as "no monthly fee".
function monthlyUnknownText(lines: readonly CommitmentLine[]): string {
const bills = lines.some(l => paymentLabel(l.payment) !== 'All upfront');
return bills ? 'Not reported (monthly fees apply)' : 'Not reported';
}

function describe(l: CommitmentLine): string {
const where = [l.account, l.region].filter(Boolean).join(', ');
const what = `${l.count} × ${l.service} ${l.resourceType}`;
const terms = `${formatTerm(l.term)}, ${paymentLabel(l.payment)}`;
return [what, terms, where].filter(Boolean).join(' · ');
}

export interface SummaryOptions {
/** Label for the upfront stat; 'Upfront on approval' when nothing is charged yet. */
upfrontLabel?: string;
}

export function buildCommitmentSummary(lines: readonly CommitmentLine[], opts: SummaryOptions = {}): HTMLElement {
const totals = commitmentTotals(lines);
const root = document.createElement('div');
root.className = 'commitment-summary';

const band = document.createElement('div');
band.className = 'commitment-summary-band';
band.append(
stat(opts.upfrontLabel ?? 'Charged today', formatCurrency(totals.upfront, '$', 2)),
stat('Monthly', totals.monthly === null ? monthlyUnknownText(lines) : formatCurrency(totals.monthly, '$', 2)),
stat('Total over term', totals.termTotal === null ? 'Not available' : formatCurrency(totals.termTotal, '$', 2)),
);
root.appendChild(band);

const list = document.createElement('ul');
list.className = 'commitment-summary-lines';
for (const l of lines.slice(0, MAX_LISTED_LINES)) {
const li = document.createElement('li');
li.textContent = describe(l);
list.appendChild(li);
}
if (lines.length > MAX_LISTED_LINES) {
const li = document.createElement('li');
li.textContent = `and ${lines.length - MAX_LISTED_LINES} more`;
list.appendChild(li);
}
root.appendChild(list);
return root;
}

/** Intro sentence followed by the summary block, as one dialog body. */
export function withIntro(intro: string, summary: HTMLElement): HTMLElement {
const root = document.createElement('div');
const p = document.createElement('p');
p.textContent = intro;
root.append(p, summary);
return root;
}
Loading
Loading