diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index 77ceef4e..d60e94e9 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -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), })); diff --git a/frontend/src/__tests__/purchase-modal-fanout-payment.test.ts b/frontend/src/__tests__/purchase-modal-fanout-payment.test.ts index 2b0d474c..ac36cfec 100644 --- a/frontend/src/__tests__/purchase-modal-fanout-payment.test.ts +++ b/frontend/src/__tests__/purchase-modal-fanout-payment.test.ts @@ -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); @@ -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('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + const paymentSelect = ec2Section.querySelector('.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[] = [ { diff --git a/frontend/src/app.ts b/frontend/src/app.ts index a06a0c3d..8e779ed2 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -622,18 +622,23 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { // 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) { diff --git a/frontend/tests-e2e/bulk-approval-confirm.spec.ts b/frontend/tests-e2e/bulk-approval-confirm.spec.ts new file mode 100644 index 00000000..395997d8 --- /dev/null +++ b/frontend/tests-e2e/bulk-approval-confirm.spec.ts @@ -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([]); +});