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
2 changes: 1 addition & 1 deletion frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ 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).
// The confirm summaries (single and fan-out, #707/#711) name each line's account.
getAccountName: jest.fn((id: string) => id),
}));

Expand Down
54 changes: 54 additions & 0 deletions frontend/src/__tests__/purchase-modal-fanout-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
import * as api from '../api';
import * as state from '../state';
import { showToast } from '../toast';
import { confirmDialog } from '../confirmDialog';
import {
getFanOutBuckets,
loadRecommendations,
} from '../recommendations';
import { formatCurrency } from '../utils';
import type { LocalRecommendation } from '../types';

beforeEach(setupPurchaseModalTest);
Expand Down Expand Up @@ -78,6 +80,58 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => {
]);
});

test('bulk approval confirm states the totals across all buckets (#707)', async () => {
const rows: LocalRecommendation[] = [
{
id: 'ec2-1-all', provider: 'aws', cloud_account_id: 'a1', service: 'ec2',
region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'all-upfront',
count: 4, upfront_cost: 4000, monthly_cost: 0, savings: 1000,
},
{
id: 'ec2-1-partial', provider: 'aws', cloud_account_id: 'a1', service: 'ec2',
region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'partial-upfront',
count: 4, upfront_cost: 2000, monthly_cost: 100, savings: 900,
},
{
id: 'rds-3-all', provider: 'aws', cloud_account_id: 'a1', service: 'rds',
region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'all-upfront',
count: 2, upfront_cost: 6000, monthly_cost: 0, savings: 1000,
},
];
(api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'all-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-1-all', 'rds-3-all']));

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await flush();
const ec2Section = Array.from(document.querySelectorAll<HTMLElement>('.fanout-bucket'))
.find((section) => section.textContent?.includes('ec2'))!;
const paymentSelect = ec2Section.querySelector<HTMLSelectElement>('.fanout-bucket-payment')!;
paymentSelect.value = 'partial-upfront';
paymentSelect.dispatchEvent(new Event('change'));

(confirmDialog as jest.Mock).mockClear();
(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: HTMLElement; confirmLabel: string };
expect(opts.title).toBe('Send 2 bulk purchases for approval?');
const text = opts.body.textContent ?? '';
// ec2 partial: $2,000 upfront + $100/mo for 12 months; rds all-upfront: $6,000.
expect(text).toContain(`Upfront on approval${formatCurrency(8000, '$', 2)}`);
expect(text).toContain(`Monthly${formatCurrency(100, '$', 2)}`);
expect(text).toContain(`Total over term${formatCurrency(9200, '$', 2)}`);
expect(text).toContain('4 脳 ec2 m5.large');
expect(text).toContain('Partial upfront');
expect(text).toContain('2 脳 rds db.r5.large');
expect(text).not.toContain('Charged today');
expect(opts.confirmLabel).toContain(`(${formatCurrency(8000, '$', 2)} upfront on approval)`);
});

test('fan-out resolves a saved payment override to its priced sibling on open', async () => {
const rows: LocalRecommendation[] = [
{
Expand Down
25 changes: 15 additions & 10 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,18 +622,23 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
// 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' },
);
// while the account is billed monthly for one or three years. The
// totals are combined across every bucket and the button carries the
// upfront amount, as on the single-purchase confirm (#707). A
// submittable bucket's recs already carry the bucket's payment option,
// so their costs are the ones that will be approved.
const summaryLines = buckets.flatMap((b) => b.recs.map((r) => commitmentLine(
r,
{ payment: b.payment, ...(r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {}) },
)));
const totals = commitmentTotals(summaryLines);
const ok = await confirmDialog({
title: `Send ${buckets.length} bulk purchase${buckets.length === 1 ? '' : 's'} for approval?`,
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',
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. Totals below are across all requests.`,
buildCommitmentSummary(summaryLines, { upfrontLabel: 'Upfront on approval' }),
),
confirmLabel: executeConfirmLabel(false, totals.upfront),
destructive: false,
});
if (!ok) {
Expand Down
39 changes: 39 additions & 0 deletions frontend/tests-e2e/bulk-approval-confirm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { mockApi, RECS, seedAuth } from './fixtures/recs';

const usd = (n: number): string => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;

// Issue #707: the bulk (fan-out) approval confirm stated only the number of
// requests. It now shows the combined upfront, monthly and term totals and
// one line per commitment, like the single-purchase confirm.
test('bulk approval confirm states the totals across every bucket', async ({ page }) => {
const api = await mockApi(page);
await seedAuth(page);
await page.route('**/api/accounts/*/service-overrides', route => route.fulfill({ json: [] }));
await page.goto('/opportunities');
const ids = ['r01', 'r08', 'r12'];
for (const id of ids) {
await page.locator(`tr.recommendation-row[data-rec-id="${id}"]`)
.getByRole('checkbox', { name: /^Select / }).check();
}
await page.locator('#bulk-purchase-btn').click();
await expect(page.locator('#fanout-summary')).toBeVisible();
await page.locator('#execute-purchase-btn').click();

const picked = RECS.filter(rec => ids.includes(rec.id));
const upfront = picked.reduce((sum, rec) => sum + rec.upfront_cost, 0);
const monthly = picked.reduce((sum, rec) => sum + rec.monthly_cost, 0);
const termTotal = picked.reduce((sum, rec) => sum + rec.upfront_cost + rec.monthly_cost * 12 * rec.term, 0);

const dialog = page.getByRole('dialog', { name: 'Send 3 bulk purchases for approval?' });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.commitment-summary-band')).toContainText(`Upfront on approval${usd(upfront)}`);
await expect(dialog.locator('.commitment-summary-band')).toContainText(`Monthly${usd(monthly)}`);
await expect(dialog.locator('.commitment-summary-band')).toContainText(`Total over term${usd(termTotal)}`);
await expect(dialog.locator('.commitment-summary-lines li')).toHaveCount(3);
await expect(dialog.getByRole('button', { name: `Send for Approval (${usd(upfront)} upfront on approval)` })).toBeVisible();

await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toHaveCount(0);
expect(api.calls.filter(call => call.method === 'POST' && call.url.includes('/purchases/execute'))).toEqual([]);
});
Loading