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
45 changes: 33 additions & 12 deletions frontend/src/__tests__/approval-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('renderApprovalDetailsBody', () => {
const body = renderApprovalDetailsBody(details, accountsById);

const stats = body.querySelectorAll('.approval-details-stat');
expect(stats.length).toBe(6);
expect(stats.length).toBe(8);
const text = body.textContent || '';
expect(text).toContain('Upfront');
expect(text).toContain('$1,200');
Expand All @@ -89,6 +89,27 @@ describe('renderApprovalDetailsBody', () => {
expect(text).toContain('Accounts');
});

it('states the monthly recurring cost and the total over the term (issue #711)', () => {
const recs = [
makeRec({ id: 'r1', term: 3, payment: 'no-upfront', upfront_cost: 0, monthly_cost: 200 }),
makeRec({ id: 'r2', term: 1, payment: 'partial-upfront', upfront_cost: 600, monthly_cost: 50 }),
];
const body = renderApprovalDetailsBody(makeDetails(recs), new Map());
const stats = Array.from(body.querySelectorAll('.approval-details-stat')).map((s) => s.textContent);
expect(stats.slice(0, 3)).toEqual(['Upfront$600', 'Monthly cost$250', 'Total over term$8,400']);
});

it('says the monthly cost is not reported instead of summing a partial monthly figure', () => {
const recs = [
makeRec({ id: 'r1', payment: 'no-upfront', upfront_cost: 0, monthly_cost: null }),
makeRec({ id: 'r2', monthly_cost: 50 }),
];
const body = renderApprovalDetailsBody(makeDetails(recs), new Map());
const stats = Array.from(body.querySelectorAll('.approval-details-stat')).map((s) => s.textContent);
expect(stats[1]).toBe('Monthly costNot reported (monthly fees apply)');
expect(stats[2]).toBe('Total over termNot available');
});

it('renders the per-rec table with all 13 columns (Monthly cost added by issue #1112)', () => {
const rec = makeRec({});
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map());
Expand Down Expand Up @@ -184,8 +205,8 @@ describe('renderApprovalDetailsBody', () => {
const details = makeDetails(recs, { estimated_savings: 0.3 });
const body = renderApprovalDetailsBody(details, new Map());
const stats = body.querySelectorAll('.approval-details-stat');
// Stat index 2 is Annual savings.
expect(stats[2]?.getAttribute('title')).toBeNull();
// Stat index 4 is Annual savings.
expect(stats[4]?.getAttribute('title')).toBeNull();
});

it('annual-savings tooltip DOES fire when backend and per-row sum genuinely disagree', () => {
Expand All @@ -195,7 +216,7 @@ describe('renderApprovalDetailsBody', () => {
const details = makeDetails(recs, { estimated_savings: 5 });
const body = renderApprovalDetailsBody(details, new Map());
const stats = body.querySelectorAll('.approval-details-stat');
expect(stats[2]?.getAttribute('title')).toBe('$120 from per-row sum');
expect(stats[4]?.getAttribute('title')).toBe('$120 from per-row sum');
});

it('counts distinct providers and accounts in the header', () => {
Expand All @@ -206,10 +227,10 @@ describe('renderApprovalDetailsBody', () => {
];
const body = renderApprovalDetailsBody(makeDetails(recs), new Map());
const stats = body.querySelectorAll('.approval-details-stat');
// Stat index 4 = Providers, index 5 = Accounts (post-Commitments).
expect(stats[3]?.querySelector('.approval-details-stat-value')?.textContent).toBe('3'); // Commitments
expect(stats[4]?.querySelector('.approval-details-stat-value')?.textContent).toBe('3'); // Providers
expect(stats[5]?.querySelector('.approval-details-stat-value')?.textContent).toBe('2'); // Accounts
// Stat index 6 = Providers, index 7 = Accounts (post-Commitments).
expect(stats[5]?.querySelector('.approval-details-stat-value')?.textContent).toBe('3'); // Commitments
expect(stats[6]?.querySelector('.approval-details-stat-value')?.textContent).toBe('3'); // Providers
expect(stats[7]?.querySelector('.approval-details-stat-value')?.textContent).toBe('2'); // Accounts
});

// Regression for CR pass-1 (PR #611): host-account rows must be
Expand All @@ -219,8 +240,8 @@ describe('renderApprovalDetailsBody', () => {
delete rec.cloud_account_id;
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map(), '909626172446');
const stats = body.querySelectorAll('.approval-details-stat');
// Stat index 5 = Accounts.
expect(stats[5]?.querySelector('.approval-details-stat-value')?.textContent).toBe('1');
// Stat index 7 = Accounts.
expect(stats[7]?.querySelector('.approval-details-stat-value')?.textContent).toBe('1');
});

it('counts "Accounts: 2" for a purchase with one host row and one named-account row', () => {
Expand All @@ -229,15 +250,15 @@ describe('renderApprovalDetailsBody', () => {
const namedRec = makeRec({ id: 'r2', provider: 'aws', cloud_account_id: 'acct-uuid-1' });
const body = renderApprovalDetailsBody(makeDetails([hostRec, namedRec]), new Map(), '909626172446');
const stats = body.querySelectorAll('.approval-details-stat');
expect(stats[5]?.querySelector('.approval-details-stat-value')?.textContent).toBe('2');
expect(stats[7]?.querySelector('.approval-details-stat-value')?.textContent).toBe('2');
});

it('does NOT count an orphan row (cloud_account_id nil AND provider !== aws) in the Accounts total', () => {
const orphanRec = makeRec({ provider: 'azure' });
delete orphanRec.cloud_account_id;
const body = renderApprovalDetailsBody(makeDetails([orphanRec]), new Map(), '909626172446');
const stats = body.querySelectorAll('.approval-details-stat');
expect(stats[5]?.querySelector('.approval-details-stat-value')?.textContent).toBe('0');
expect(stats[7]?.querySelector('.approval-details-stat-value')?.textContent).toBe('0');
});

it('escapes user-controlled string values in the table', () => {
Expand Down
49 changes: 48 additions & 1 deletion frontend/src/__tests__/commitmentSummary.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { buildCommitmentSummary, commitmentTotals, paymentLabel, withIntro, type CommitmentLine } from '../commitmentSummary';
import {
billsMonthly, buildCommitmentBand, buildCommitmentSummary, commitmentLine, commitmentTotals, formatCommitmentTotals,
paymentLabel, withIntro, type CommitmentLine,
} from '../commitmentSummary';

const line = (over: Partial<CommitmentLine> = {}): CommitmentLine => ({
service: 'ec2',
Expand Down Expand Up @@ -86,3 +89,47 @@ describe('buildCommitmentSummary', () => {
expect(body.querySelector('.commitment-summary')).not.toBeNull();
});
});

describe('commitmentLine (issue #711)', () => {
const rec = {
service: 'rds', provider: 'aws', region: 'us-east-1', resource_type: 'db.r5.large',
count: 2, term: 3, payment: 'partial-upfront', upfront_cost: 900, monthly_cost: 40,
};

it('maps a priced recommendation onto a line', () => {
expect(commitmentLine(rec)).toEqual({
service: 'rds', provider: 'aws', region: 'us-east-1', resourceType: 'db.r5.large',
count: 2, term: 3, payment: 'partial-upfront', upfront: 900, monthly: 40,
});
});

it('takes a bucket payment and account, and keeps an absent monthly cost as null', () => {
const l = commitmentLine({ ...rec, monthly_cost: undefined }, { payment: 'no-upfront', account: 'prod' });
expect(l.payment).toBe('no-upfront');
expect(l.account).toBe('prod');
expect(l.monthly).toBeNull();
});
});

describe('formatCommitmentTotals and buildCommitmentBand (issue #711)', () => {
it('formats the three totals at the requested precision', () => {
expect(formatCommitmentTotals([line({ monthly: 100.4, term: 1 })], 0))
.toEqual({ upfront: '$0', monthly: '$100', termTotal: '$1,205' });
});

it('never prints a partial monthly sum when one line is unreported', () => {
expect(formatCommitmentTotals([line(), line({ monthly: null })]))
.toEqual({ upfront: '$0.00', monthly: 'Not reported (monthly fees apply)', termTotal: 'Not available' });
});

it('billsMonthly is false only when every line is all upfront', () => {
expect(billsMonthly([line({ payment: 'all-upfront' })])).toBe(false);
expect(billsMonthly([line({ payment: 'all-upfront' }), line()])).toBe(true);
});

it('builds the band alone, without the line list', () => {
const band = buildCommitmentBand([line({ upfront: 50, monthly: 10, term: 1 })], { upfrontLabel: 'Upfront' });
expect(band.textContent).toBe('Upfront$50.00Monthly$10.00Total over term$170.00');
expect(band.querySelector('li')).toBeNull();
});
});
2 changes: 2 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ jest.mock('../recommendations', () => ({
fanOutBucketAccountLabel: jest.fn(
(b: { recs: Array<{ cloud_account_id?: string }> }) => b.recs[0]?.cloud_account_id || 'Unattributed',
),
// The fan-out confirm names each line's account (issue #711).
getAccountName: jest.fn((id: string) => id),
}));

jest.mock('../plans', () => ({
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2007,3 +2007,77 @@ describe('Issue #333: bulk purchase buckets never span more than one cloud accou
expect(unattributedBucket.recs).toHaveLength(1);
});
});

describe('Issue #711: purchase screens state the monthly charge and the term total', () => {
// v-1-no: 1 year, no upfront, $800 a month, so the term costs $9,600.
const noUpfront = (): LocalRecommendation => buildRows()[3]!;

test('the single purchase modal shows upfront, monthly and total over term for the checked rows', async () => {
await openPurchaseModal([noUpfront()]);
const band = document.getElementById('purchase-modal-commitment-band')!;
expect(band.textContent).toBe('Upfront$0.00Monthly$800.00Total over term$9,600.00');

(document.querySelector('.purchase-modal-row-include') as HTMLInputElement).click();
expect(band.textContent).toBe('Upfront$0.00Monthly$0.00Total over term$0.00');
});

test('the Execute Now warning names the monthly charge of a no-upfront purchase', async () => {
await openPurchaseModal([noUpfront()]);
(document.getElementById('execute-mode-direct') as HTMLInputElement).click();
expect(document.querySelector<HTMLElement>('.direct-execute-warning')!.textContent).toBe(
'Warning: This will charge $0.00 upfront immediately, then $800.00 a month for the term ($9,600.00 in total). This bypasses the approval step.',
);
});

test('the Execute Now warning says monthly fees apply when the provider did not report them', async () => {
await openPurchaseModal([{ ...noUpfront(), monthly_cost: null }]);
(document.getElementById('execute-mode-direct') as HTMLInputElement).click();
expect(document.querySelector<HTMLElement>('.direct-execute-warning')!.textContent).toContain(
'upfront immediately, plus monthly fees the provider did not report.',
);
});

test('the bulk summary, bucket totals and confirm state the monthly charge and term total', async () => {
const rows: LocalRecommendation[] = [
{
id: 'ec2-no', provider: 'aws', cloud_account_id: 'a1', service: 'ec2',
region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'no-upfront',
count: 1, upfront_cost: 0, monthly_cost: 100, savings: 50,
},
{
id: 'rds-no', provider: 'aws', cloud_account_id: 'a1', service: 'rds',
region: 'us-east-1', resource_type: 'db.r5.large', term: 1, payment: 'no-upfront',
count: 1, upfront_cost: 0, monthly_cost: 200, savings: 80,
},
];
(api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } });
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: rows, regions: [] });
(state.getRecommendations as jest.Mock).mockReturnValue(rows);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue(rows);
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-no', 'rds-no']));

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await flush();

const line = (label: string): string | null | undefined => Array.from(document.querySelectorAll('#fanout-summary p'))
.find((p) => p.textContent?.startsWith(label))?.querySelector('strong')?.textContent;
expect(line('Total upfront')).toBe('$0');
expect(line('Monthly recurring')).toBe('$300');
expect(line('Total over term')).toBe('$3,600');
const bucketTotals = Array.from(document.querySelectorAll('.fanout-bucket-totals')).map((p) => p.textContent);
expect(bucketTotals).toEqual(expect.arrayContaining([
expect.stringContaining('$0 upfront · $100/mo recurring'),
expect.stringContaining('$0 upfront · $200/mo recurring'),
]));

(confirmDialog as jest.Mock).mockClear();
(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('Upfront on approval$0.00');
expect(text).toContain('Monthly$300.00');
expect(text).toContain('Total over term$3,600.00');
});
});
30 changes: 16 additions & 14 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
import { formatPaymentAdjustmentNotice } from './commitmentOptions';
import { confirmDialog } from './confirmDialog';
import { buildCommitmentSummary, commitmentTotals, executeButtonLabel, executeConfirmLabel, withIntro } from './commitmentSummary';
import { buildCommitmentSummary, commitmentLine, commitmentTotals, executeButtonLabel, executeConfirmLabel, withIntro } from './commitmentSummary';
import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
Expand Down Expand Up @@ -432,18 +432,10 @@ 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 summaryLines = 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) } : {}),
}));
const summaryLines = recsWithPayment.map((r) => commitmentLine(
r,
r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {},
));
const totals = commitmentTotals(summaryLines);
const summary = buildCommitmentSummary(
summaryLines,
Expand Down Expand Up @@ -599,9 +591,19 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
// Same approval-required default as the single-purchase path: each
// bucket POSTs a request that triggers an approval email; the actual
// charges fire when each approver clicks the link in their email.
// State the monthly charge and the term total too (issue #711): for
// no-upfront and partial-upfront buckets the upfront alone reads $0
// while the account is billed monthly for one or three years.
const summary = buildCommitmentSummary(
buckets.flatMap((b) => b.recs.map((r) => commitmentLine(
r,
{ payment: b.payment, ...(r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {}) },
))),
{ upfrontLabel: 'Upfront on approval' },
);
const ok = await confirmDialog({
title: `Send ${buckets.length} bulk purchase${buckets.length === 1 ? '' : 's'} for approval?`,
body: `This will submit ${buckets.length} separate purchase request${buckets.length === 1 ? '' : 's'} and email ${buckets.length} approval request${buckets.length === 1 ? '' : 's'}. Each must be approved individually before its commitments are charged.`,
body: withIntro(`This will submit ${buckets.length} separate purchase request${buckets.length === 1 ? '' : 's'} and email ${buckets.length} approval request${buckets.length === 1 ? '' : 's'}. Each must be approved individually before its commitments are charged.`, summary),
confirmLabel: 'Send all for approval',
destructive: false,
});
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/approval-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
*
* This module fetches `/api/purchases/{id}` + `/api/accounts` and builds
* an HTMLElement carrying:
* - An aggregate header (total upfront, total monthly + annual
* savings, recommendation count, distinct providers, distinct
* accounts touched).
* - An aggregate header (total upfront, monthly recurring cost, total
* over the term, total monthly + annual savings, recommendation
* count, distinct providers, distinct accounts touched).
* - A per-recommendation table: account / provider / service /
* resource type / engine / region / count / term + payment /
* upfront / monthly savings / effective savings % when the rec
Expand All @@ -28,6 +28,7 @@ import type { CloudAccount } from './api/accounts';
import type { PurchaseDetails, Recommendation } from './api/types';
import { escapeHtml, formatCurrency, formatTerm, amortizedMonthly } from './utils';
import { getAmortizeUpfront } from './state';
import { commitmentLine, formatCommitmentTotals } from './commitmentSummary';

/**
* accountsById maps the internal CloudAccount UUID (the value carried
Expand Down Expand Up @@ -107,7 +108,13 @@ function renderApprovalDetailsHeader(details: PurchaseDetails, recs: Recommendat
// sub-cent level, well below the precision formatCurrency renders.
const annualMismatch = Math.abs(totalAnnual - monthly * 12) > 0.005;

// Issue #711: an approver must see the recurring charge and what the
// whole term costs, not only the upfront (which is $0 for no-upfront).
const termText = formatCommitmentTotals(recs.map((r) => commitmentLine(r)), 0);

header.appendChild(headerStat('Upfront', formatCurrency(upfront)));
header.appendChild(headerStat('Monthly cost', termText.monthly));
header.appendChild(headerStat('Total over term', termText.termTotal));
header.appendChild(headerStat('Monthly savings', formatCurrency(monthly)));
header.appendChild(headerStat('Annual savings', formatCurrency(monthly * 12), undefined, annualMismatch ? `${formatCurrency(totalAnnual)} from per-row sum` : undefined));
header.appendChild(headerStat('Commitments', String(recs.length)));
Expand Down
Loading
Loading