From 6f6991c3f3bf37dea21920a412b83b346353cc0d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 15 Sep 2026 00:42:22 +0200 Subject: [PATCH 1/3] fix(frontend): reprice complete fan-out purchase variants Resolve bucket and per-row payments to complete priced records, preserve explicit account overrides, and scale loaded quantities exactly once. Keep unavailable choices visible without hiding viable alternatives. Exclude a whole unresolved bucket from totals and submission. Verify actual modal submissions, IDs, financial totals, inherited and explicit choices, rollback, and Savings Plans breakdowns at half capacity. Closes #2070 --- .../__tests__/purchase-modal-submit.test.ts | 1045 ++++++++++++++++- .../src/__tests__/recommendations.test.ts | 56 +- frontend/src/recommendations.ts | 260 ++-- 3 files changed, 1235 insertions(+), 126 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 2d9a43a39..474c2e469 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -933,7 +933,7 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { } }); - test('T9 repairing the bucket un-skips it everywhere', async () => { + test('T9 an unavailable bucket cannot be repaired by label-only mutation', async () => { const [ec2Rec, rdsRec] = buildFanOutRows(); (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); (api.getRecommendations as jest.Mock).mockResolvedValue({ @@ -950,21 +950,22 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) .find((s) => s.querySelector('.fanout-bucket-error') != null)!; const rdsPaymentSelect = rdsSection.querySelector('.fanout-bucket-payment')!; + expect(rdsPaymentSelect.disabled).toBe(true); rdsPaymentSelect.value = 'partial-upfront'; rdsPaymentSelect.dispatchEvent(new Event('change')); - expect(rdsSection.querySelector('.fanout-bucket-ok')).not.toBeNull(); + expect(rdsSection.querySelector('.fanout-bucket-error')).not.toBeNull(); const summaryText = document.getElementById('fanout-summary')!.textContent ?? ''; - expect(summaryText).toContain('Will send 2 approval emails'); - expect(summaryText).not.toContain('skipped'); + expect(summaryText).toContain('Will send 1 approval email'); + expect(summaryText).toContain('1 incompatible bucket will be skipped'); const totalUpfrontLine = Array.from(document.querySelectorAll('#fanout-summary p')) .find((p) => p.textContent?.startsWith('Total upfront'))!; - expect(totalUpfrontLine.querySelector('strong')!.textContent).toBe(formatCurrency(1000)); + expect(totalUpfrontLine.querySelector('strong')!.textContent).toBe(formatCurrency(0)); (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); await flush(); - expect(api.executePurchase).toHaveBeenCalledTimes(2); + expect(api.executePurchase).toHaveBeenCalledTimes(1); }); test('T10 nothing submittable disables Execute', async () => { @@ -1044,7 +1045,9 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { (api.executePurchase as jest.Mock) .mockReturnValueOnce(requests[0]!.promise) .mockReturnValueOnce(requests[1]!.promise); - const rows = buildFanOutRows(); + const rows = buildFanOutRows().map((row) => row.service === 'rds' + ? { ...row, payment: 'partial-upfront' as const, upfront_cost: 1000 } + : row); (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'partial-upfront' } }); (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: rows, regions: [], @@ -1083,7 +1086,9 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { }); test('fan-out clears submitting state when result processing throws', async () => { - const rows = buildFanOutRows(); + const rows = buildFanOutRows().map((row) => row.service === 'rds' + ? { ...row, payment: 'partial-upfront' as const, upfront_cost: 1000 } + : row); (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'partial-upfront' } }); (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: rows, regions: [], @@ -1109,4 +1114,1028 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { expect(executeBtn.dataset['submitting']).toBeUndefined(); expect(executeBtn.disabled).toBe(false); }); + + test('fan-out payment changes replace the priced sibling in the displayed totals and POST', 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(); + expect(document.querySelectorAll('.fanout-bucket')).toHaveLength(2); + + 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')); + + const liveEc2Section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + expect(liveEc2Section.querySelector('.fanout-bucket-totals')!.textContent).toContain('$2,000'); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const ec2Body = (api.executePurchase as jest.Mock).mock.calls + .map(([body]) => body as Array>) + .find((body) => body.some((rec) => rec['service'] === 'ec2'))!; + expect(ec2Body).toEqual([ + expect.objectContaining({ + id: 'ec2-1-partial', payment: 'partial-upfront', count: 4, upfront_cost: 2000, monthly_cost: 100, + }), + ]); + }); + + test('fan-out resolves a saved payment override to its priced sibling on open', 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.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr-a1-ec2', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }, + ]); + (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'))!; + expect(ec2Section.querySelector('.fanout-bucket-payment')!.disabled).toBe(false); + expect(ec2Section.querySelector('.fanout-per-rec-payment')).toBeNull(); + expect(ec2Section.querySelector('.fanout-bucket-totals')!.textContent).toContain('$2,000'); + expect(getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!.recs[0]).toMatchObject({ + id: 'ec2-1-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100, + }); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual([expect.objectContaining({ + id: 'ec2-1-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100, + })]); + }); + + test('fan-out falls from an unpriced saved override to the row payment before toolbar default', async () => { + const rows: LocalRecommendation[] = [ + { + 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: 2, upfront_cost: 1000, monthly_cost: 50, savings: 400, + }, + { + 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: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + 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: 1, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr-a1-ec2', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'no-upfront' }, + ]); + (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-partial', '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'))!; + expect(ec2Section.querySelector('.fanout-bucket-totals')!.textContent).toContain('$1,000'); + expect(getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!.recs[0]).toMatchObject({ + id: 'ec2-1-partial', payment: 'partial-upfront', upfront_cost: 1000, monthly_cost: 50, + }); + }); + + test('fan-out initialization keeps sibling IDs and capacity scaling at 50 percent', 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, + }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr-a1-ec2', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }, + ]); + (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 ec2Bucket = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(ec2Bucket.recs[0]).toMatchObject({ + id: 'ec2-1-partial', payment: 'partial-upfront', count: 2, recommended_count: 4, + upfront_cost: 1000, monthly_cost: 50, + }); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual([expect.objectContaining({ + id: 'ec2-1-partial', count: 2, recommended_count: 4, upfront_cost: 1000, monthly_cost: 50, + })]); + }); + + test('fan-out repeated Savings Plans row swaps preserve 50 percent totals and POST records', async () => { + const details = { plan_type: 'EC2Instance', instance_family: 'm5', region: 'us-east-1' }; + const rows: LocalRecommendation[] = [ + ...(['a1', 'a2'] as const).flatMap((account) => [ + { + id: `${account}-sp-all`, provider: 'aws' as const, cloud_account_id: account, service: 'savings-plans-ec2instance', + region: '', resource_type: '', term: 1 as const, payment: 'all-upfront' as const, + count: 4, upfront_cost: 4000, monthly_cost: 0, savings: 1000, details, + }, + { + id: `${account}-sp-partial`, provider: 'aws' as const, cloud_account_id: account, service: 'savings-plans-ec2instance', + region: '', resource_type: '', term: 1 as const, payment: 'partial-upfront' as const, + count: 4, upfront_cost: 2000, monthly_cost: 100, savings: 900, details, + }, + { + id: `${account}-sp-no`, provider: 'aws' as const, cloud_account_id: account, service: 'savings-plans-ec2instance', + region: '', resource_type: '', term: 1 as const, payment: 'no-upfront' as const, + count: 4, upfront_cost: 0, monthly_cost: 200, savings: 800, details, + }, + ]), + { + id: 'a1-sp-sm-all', provider: 'aws' as const, cloud_account_id: 'a1', service: 'savings-plans-sagemaker', + region: '', resource_type: '', term: 1 as const, payment: 'all-upfront' as const, + count: 4, upfront_cost: 3000, monthly_cost: 0, savings: 700, + details: { plan_type: 'SageMaker', instance_family: '', region: 'us-east-1' }, + }, + { + id: 'a1-sp-sm-partial', provider: 'aws' as const, cloud_account_id: 'a1', service: 'savings-plans-sagemaker', + region: '', resource_type: '', term: 1 as const, payment: 'partial-upfront' as const, + count: 4, upfront_cost: 1500, monthly_cost: 75, savings: 650, + details: { plan_type: 'SageMaker', instance_family: '', region: 'us-east-1' }, + }, + { + id: 'a1-sp-sm-no', provider: 'aws' as const, cloud_account_id: 'a1', service: 'savings-plans-sagemaker', + region: '', resource_type: '', term: 1 as const, payment: 'no-upfront' as const, + count: 4, upfront_cost: 0, monthly_cost: 150, savings: 600, + details: { plan_type: 'SageMaker', instance_family: '', region: 'us-east-1' }, + }, + { + id: 'rds-3-all', provider: 'aws' as const, cloud_account_id: 'a3', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 6000, monthly_cost: 0, savings: 1000, + }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['a1-sp-all', 'a2-sp-all', 'a1-sp-sm-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const bucket = getFanOutBuckets()!.find((candidate) => candidate.service === 'savings-plans')!; + expect(bucket.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-sp-all', service: 'savings-plans-ec2instance', count: 2, recommended_count: 4, upfront_cost: 2000 }), + expect.objectContaining({ id: 'a2-sp-all', service: 'savings-plans-ec2instance', count: 2, recommended_count: 4, upfront_cost: 2000 }), + ])); + const section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('Savings Plans'))!; + const initialDetails = section.querySelector('details')!; + expect(initialDetails).toBeTruthy(); + initialDetails.open = true; + expect(section.querySelectorAll('.fanout-sp-plan-type-row')).toHaveLength(2); + expect(section.textContent).toContain('$4,000 upfront'); + expect(section.textContent).toContain('$1,500 upfront'); + const a1 = section.querySelector('[data-rec-id="a1-sp-all"]')!; + a1.value = 'partial-upfront'; + a1.dispatchEvent(new Event('change')); + let liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('Savings Plans'))!; + const liveA1 = liveSection.querySelector('[data-rec-id="a1-sp-partial"]')!; + expect(liveA1.isConnected).toBe(true); + expect(liveSection.querySelector('details')!.open).toBe(true); + expect(document.activeElement).toBe(liveA1); + liveA1.value = 'no-upfront'; + liveA1.dispatchEvent(new Event('change')); + liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('Savings Plans'))!; + expect(liveSection.querySelector('details')!.open).toBe(true); + const liveA2 = liveSection.querySelector('[data-rec-id="a2-sp-all"]')!; + liveA2.value = 'partial-upfront'; + liveA2.dispatchEvent(new Event('change')); + liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('Savings Plans'))!; + const liveA2Partial = liveSection.querySelector('[data-rec-id="a2-sp-partial"]')!; + expect(liveA2Partial.isConnected).toBe(true); + expect(liveSection.querySelector('details')!.open).toBe(true); + expect(document.activeElement).toBe(liveA2Partial); + const finalBucket = getFanOutBuckets()!.find((candidate) => candidate.service === 'savings-plans')!; + expect(finalBucket.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-sp-no', service: 'savings-plans-ec2instance', payment: 'no-upfront', count: 2, recommended_count: 4, upfront_cost: 0, monthly_cost: 100 }), + expect.objectContaining({ id: 'a2-sp-partial', service: 'savings-plans-ec2instance', payment: 'partial-upfront', count: 2, recommended_count: 4, upfront_cost: 1000, monthly_cost: 50 }), + expect.objectContaining({ id: 'a1-sp-sm-all', service: 'savings-plans-sagemaker', payment: 'all-upfront', count: 2, recommended_count: 4, upfront_cost: 1500 }), + ])); + expect(liveSection.querySelector('.fanout-bucket-totals')!.textContent).toContain('$2,500'); + expect(liveSection.textContent).toContain('$1,000 upfront'); + expect(liveSection.textContent).toContain('$1,500 upfront'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total commitments: 7'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total upfront: $5,500'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total savings / mo: $1,700'); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const spCall = (api.executePurchase as jest.Mock).mock.calls + .find(([payload]) => (payload as Array>).some((rec) => rec['service'] === 'savings-plans-ec2instance'))!; + const body = spCall[0] as Array>; + expect(spCall[1]).toBe(50); + expect(body).toHaveLength(3); + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-sp-no', service: 'savings-plans-ec2instance', payment: 'no-upfront', count: 2, recommended_count: 4, upfront_cost: 0, monthly_cost: 100, savings: 400 }), + expect.objectContaining({ id: 'a2-sp-partial', service: 'savings-plans-ec2instance', payment: 'partial-upfront', count: 2, recommended_count: 4, upfront_cost: 1000, monthly_cost: 50, savings: 450 }), + expect.objectContaining({ id: 'a1-sp-sm-all', service: 'savings-plans-sagemaker', payment: 'all-upfront', count: 2, recommended_count: 4, upfront_cost: 1500, monthly_cost: 0, savings: 350 }), + ])); + expect(body.some((rec) => rec['payment'] === '__fanout-bucket-default__')).toBe(false); + }); + + test('fan-out resolves a viable legacy sibling instead of repairing an unpriced label', 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: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'rds-3-no', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'no-upfront', + count: 2, upfront_cost: 0, monthly_cost: 800, savings: 500, + }, + { + 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: '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-1-all', 'rds-3-no'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('rds'))!; + expect(rdsSection.querySelector('.fanout-bucket-totals')!.textContent).toContain('$6,000'); + expect(getFanOutBuckets()!.find((bucket) => bucket.service === 'rds')!.recs[0]).toMatchObject({ + id: 'rds-3-all', payment: 'all-upfront', upfront_cost: 6000, monthly_cost: 0, + }); + }); + + test('fan-out resolves full priced siblings per account in a mixed bucket', async () => { + const rows: LocalRecommendation[] = [ + { + id: 'a1-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: 'a1-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: 'a2-all', provider: 'aws', cloud_account_id: 'a2', 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: 'a2-no', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'no-upfront', + count: 4, upfront_cost: 0, monthly_cost: 200, savings: 800, + }, + { + id: 'rds-3-all', provider: 'aws', cloud_account_id: 'a3', 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.listAccountServiceOverrides as jest.Mock).mockImplementation(async (accountID: string) => { + if (accountID === 'a1') return [{ id: 'ovr-a1', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }]; + if (accountID === 'a2') return [{ id: 'ovr-a2', account_id: 'a2', provider: 'aws', service: 'ec2', payment: 'no-upfront' }]; + return []; + }); + (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(['a1-all', 'a2-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const ec2Bucket = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(ec2Bucket.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 2000 }), + expect.objectContaining({ id: 'a2-no', payment: 'no-upfront', upfront_cost: 0 }), + ])); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 2000 }), + expect.objectContaining({ id: 'a2-no', payment: 'no-upfront', upfront_cost: 0 }), + ])); + }); + + test('fan-out preserves an equal-default multi-account override through bucket edits', async () => { + const rows: LocalRecommendation[] = [ + { + id: 'a1-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: 'a1-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: 'a2-all', provider: 'aws', cloud_account_id: 'a2', 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: 'a2-partial', provider: 'aws', cloud_account_id: 'a2', 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: 'a3', 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.listAccountServiceOverrides as jest.Mock).mockImplementation(async (accountID: string) => + accountID === 'a1' ? [{ id: 'ovr-a1', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'all-upfront' }] : []); + (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(['a1-all', 'a2-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const initial = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(initial.perRecPayments?.get('a1-all')).toBe('all-upfront'); + expect(initial.perRecPayments?.has('a2-all')).toBe(false); + const ec2Section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + const bucketSelect = ec2Section.querySelector('.fanout-bucket-payment')!; + bucketSelect.value = 'partial-upfront'; + bucketSelect.dispatchEvent(new Event('change')); + + const edited = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(edited.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', upfront_cost: 4000, monthly_cost: 0 }), + expect.objectContaining({ id: 'a2-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100 }), + ])); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', upfront_cost: 4000, monthly_cost: 0 }), + expect.objectContaining({ id: 'a2-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100 }), + ])); + }); + + test('fan-out row controls distinguish explicit equal-default from bucket inheritance', async () => { + const rows: LocalRecommendation[] = [ + { + id: 'a1-all', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'all-upfront', count: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'a1-partial', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'partial-upfront', count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + { + id: 'a2-all', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'all-upfront', count: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'a2-partial', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'partial-upfront', count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + { + id: 'a1-no', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'no-upfront', count: 2, upfront_cost: 0, monthly_cost: 200, savings: 400, + }, + { + id: 'a2-no', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1, payment: 'no-upfront', count: 2, upfront_cost: 0, monthly_cost: 200, savings: 400, + }, + { + id: 'rds-3-all', provider: 'aws', cloud_account_id: 'a3', service: 'rds', region: 'us-east-1', + resource_type: 'db.r5.large', term: 3, payment: 'all-upfront', count: 1, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (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(['a1-all', 'a2-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'))!; + let a1Select = ec2Section.querySelector('[data-rec-id="a1-all"]')!; + expect(a1Select.isConnected).toBe(true); + expect(a1Select.options[0]!.textContent).toContain('Use bucket default'); + expect(a1Select.options[0]!.selected).toBe(true); + a1Select.value = 'all-upfront'; + a1Select.dispatchEvent(new Event('change')); + let liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + a1Select = liveSection.querySelector('[data-rec-id="a1-all"]')!; + expect(a1Select.isConnected).toBe(true); + + const bucketSelect = liveSection.querySelector('.fanout-bucket-payment')!; + bucketSelect.value = 'partial-upfront'; + bucketSelect.dispatchEvent(new Event('change')); + const liveA1 = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!.recs; + expect(liveA1).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', upfront_cost: 2000 }), + expect.objectContaining({ id: 'a2-partial', payment: 'partial-upfront', upfront_cost: 1000 }), + ])); + + liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + const a1Explicit = liveSection.querySelector('[data-rec-id="a1-all"]')!; + const a2Live = liveSection.querySelector('[data-rec-id="a2-partial"]')!; + expect(a1Explicit.isConnected).toBe(true); + expect(a2Live.isConnected).toBe(true); + a1Explicit.value = '__fanout-bucket-default__'; + a1Explicit.dispatchEvent(new Event('change')); + const afterInheritance = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(afterInheritance.perRecPayments?.has('a1-all')).toBe(false); + expect(afterInheritance.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 1000 }), + expect.objectContaining({ id: 'a2-partial', payment: 'partial-upfront', upfront_cost: 1000 }), + ])); + liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + expect(liveSection.querySelector('[data-rec-id="a1-partial"]')?.isConnected).toBe(true); + const liveBucketSelect = liveSection.querySelector('.fanout-bucket-payment')!; + liveBucketSelect.value = 'no-upfront'; + liveBucketSelect.dispatchEvent(new Event('change')); + const afterSecondBucketEdit = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(afterSecondBucketEdit.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-no', payment: 'no-upfront' }), + expect.objectContaining({ id: 'a2-no', payment: 'no-upfront' }), + ])); + liveSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('ec2'))!; + expect(liveSection.querySelector('[data-rec-id="a1-no"]')?.isConnected).toBe(true); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-no', payment: 'no-upfront', upfront_cost: 0, monthly_cost: 200 }), + expect.objectContaining({ id: 'a2-no', payment: 'no-upfront', upfront_cost: 0, monthly_cost: 200 }), + ])); + expect(body.some((rec) => rec['payment'] === '__fanout-bucket-default__')).toBe(false); + }); + + test('fan-out row rollback preserves the live inheritance sentinel when a sibling disappears', async () => { + const loadedRows: LocalRecommendation[] = [ + { + id: 'a1-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: 'a1-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: 'a2-all', provider: 'aws', cloud_account_id: 'a2', 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: 'a2-partial', provider: 'aws', cloud_account_id: 'a2', 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: 'a3', service: 'rds', region: 'us-east-1', + resource_type: 'db.r5.large', term: 3, payment: 'all-upfront', count: 1, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'all-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: loadedRows, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['a1-all', 'a2-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + let section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + let liveA1 = section.querySelector('[data-rec-id="a1-all"]')!; + expect(liveA1.value).toBe('__fanout-bucket-default__'); + expect(liveA1.isConnected).toBe(true); + loadedRows.splice(loadedRows.findIndex((row) => row.id === 'a1-partial'), 1); + liveA1.value = 'partial-upfront'; + liveA1.dispatchEvent(new Event('change')); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })); + expect(liveA1.isConnected).toBe(true); + expect(liveA1.value).toBe('__fanout-bucket-default__'); + const bucket = getFanOutBuckets()!.find((candidate) => candidate.service === 'ec2')!; + expect(bucket.perRecPayments?.has('a1-all')).toBe(false); + expect(bucket.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', upfront_cost: 4000 }), + expect.objectContaining({ id: 'a2-all', payment: 'all-upfront', upfront_cost: 4000 }), + ])); + section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + liveA1 = section.querySelector('[data-rec-id="a1-all"]')!; + expect(liveA1.value).toBe('__fanout-bucket-default__'); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', upfront_cost: 4000 }), + expect.objectContaining({ id: 'a2-all', payment: 'all-upfront', upfront_cost: 4000 }), + ])); + expect(body.some((rec) => rec['payment'] === '__fanout-bucket-default__')).toBe(false); + }); + + test('fan-out repairs an explicit row after its same-ID priced variant reaches zero at 50 percent', async () => { + const loadedRows: LocalRecommendation[] = [ + ...(['a1', 'a2'] as const).flatMap((account) => [ + { + id: `${account}-all`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: `${account}-partial`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'partial-upfront' as const, + count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + ]), + { + id: 'rds-3-all', provider: 'aws' as const, cloud_account_id: 'a3', service: 'rds', region: 'us-east-1', + resource_type: 'db.r5.large', term: 3 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'all-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: loadedRows, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['a1-all', 'a2-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + let section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + const a1 = section.querySelector('[data-rec-id="a1-all"]')!; + a1.value = 'partial-upfront'; + a1.dispatchEvent(new Event('change')); + section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + const a2 = section.querySelector('[data-rec-id="a2-all"]')!; + loadedRows.find((row) => row.id === 'a1-partial')!.count = 0; + a2.value = 'partial-upfront'; + a2.dispatchEvent(new Event('change')); + + section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + const unavailableA1 = section.querySelector('[data-rec-id="a1-partial"]')!; + expect(unavailableA1.isConnected).toBe(true); + expect(unavailableA1.disabled).toBe(false); + expect(unavailableA1.options[0]).toMatchObject({ value: '', disabled: true, selected: true }); + expect(Array.from(unavailableA1.options, (option) => option.value)).toContain('all-upfront'); + unavailableA1.value = 'all-upfront'; + unavailableA1.dispatchEvent(new Event('change')); + section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + expect(section.querySelector('[data-rec-id="a1-all"]')?.isConnected).toBe(true); + const repaired = getFanOutBuckets()!.find((candidate) => candidate.service === 'ec2')!; + expect(repaired.recs).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', count: 1, recommended_count: 2, upfront_cost: 1000 }), + ])); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'ec2'))!; + expect(body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-all', payment: 'all-upfront', count: 1, upfront_cost: 1000 }), + ])); + }); + + test('fan-out skips an unresolved mixed legacy bucket while posting a valid bucket', async () => { + const rows: LocalRecommendation[] = [ + { + id: 'ec2-a1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: undefined, + count: 2, upfront_cost: 1000, monthly_cost: 0, savings: 300, + }, + { + id: 'ec2-a2', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: undefined, + count: 2, upfront_cost: 1100, monthly_cost: 0, savings: 320, + }, + { + id: 'ec2-a1-partial', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'partial-upfront', + count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + { + id: 'rds-3-all', provider: 'aws', cloud_account_id: 'a3', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'all-upfront', + count: 1, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (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-a1', 'ec2-a2', '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'))!; + expect(ec2Section.textContent).toContain('2 commitments'); + const legacySelects = Array.from(ec2Section.querySelectorAll('.fanout-per-rec-payment')); + expect(legacySelects).toHaveLength(2); + const a1Legacy = legacySelects.find((select) => select.dataset['recId'] === 'ec2-a1')!; + const a2Legacy = legacySelects.find((select) => select.dataset['recId'] === 'ec2-a2')!; + expect(a1Legacy.disabled).toBe(false); + const a1Inherit = Array.from(a1Legacy.options).find((option) => option.value === '__fanout-bucket-default__')!; + const a1Unavailable = Array.from(a1Legacy.options).find((option) => option.value === '')!; + expect(a1Inherit.disabled).toBe(true); + expect(a1Inherit.selected).toBe(false); + expect(a1Unavailable.disabled).toBe(true); + expect(a1Unavailable.selected).toBe(true); + expect(Array.from(a1Legacy.options, (option) => option.value)).toContain('partial-upfront'); + expect(a2Legacy.disabled).toBe(true); + expect(a2Legacy.options[0]?.textContent).toBe('Unavailable: no priced payment'); + rows.splice(rows.findIndex((row) => row.id === 'ec2-a1-partial'), 1); + a1Legacy.value = 'partial-upfront'; + a1Legacy.dispatchEvent(new Event('change')); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })); + expect(a1Legacy.isConnected).toBe(true); + expect(a1Legacy.value).toBe(''); + expect(a1Legacy.selectedOptions).toHaveLength(1); + expect(a1Legacy.selectedOptions[0]!.value).toBe(''); + expect(getFanOutBuckets()!.some((bucket) => bucket.service === 'ec2')).toBe(false); + expect(document.getElementById('fanout-summary')!.textContent).toContain('$3,000'); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + const body = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + expect(body).toEqual([expect.objectContaining({ id: 'rds-3-all', service: 'rds', upfront_cost: 3000 })]); + expect(body.some((rec) => rec['payment'] === 'partial-upfront' || rec['id'] === 'ec2-a1')).toBe(false); + }); + + + test('fan-out payment changes keep capacity scaling and recommended_count on the sibling', 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, + }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(); + expect(document.querySelectorAll('.fanout-bucket')).toHaveLength(2); + + 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')); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const ec2Body = (api.executePurchase as jest.Mock).mock.calls + .map(([body]) => body as Array>) + .find((body) => body.some((rec) => rec['service'] === 'ec2'))!; + expect(ec2Body).toEqual([ + expect.objectContaining({ + id: 'ec2-1-partial', payment: 'partial-upfront', count: 2, recommended_count: 4, + upfront_cost: 1000, monthly_cost: 50, + }), + ]); + }); + + test('fan-out bucket edits preserve explicit full records and submitted payment overrides', async () => { + const rows: LocalRecommendation[] = [ + { + id: 'a1-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: 'a1-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: 'a1-no', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'no-upfront', + count: 4, upfront_cost: 0, monthly_cost: 200, savings: 800, + }, + { + id: 'a2-all', provider: 'aws', cloud_account_id: 'a2', 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: 'a2-partial', provider: 'aws', cloud_account_id: 'a2', 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: 'a2-no', provider: 'aws', cloud_account_id: 'a2', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'no-upfront', + count: 4, upfront_cost: 0, monthly_cost: 200, savings: 800, + }, + { + id: 'rds-3-all', provider: 'aws', cloud_account_id: 'a3', 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.listAccountServiceOverrides as jest.Mock).mockImplementation(async (accountID: string) => + accountID === 'a1' ? [{ id: 'override-a1', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }] : []); + (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(['a1-all', 'a2-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 = 'no-upfront'; + paymentSelect.dispatchEvent(new Event('change')); + + const ec2Bucket = getFanOutBuckets()!.find((bucket) => bucket.service === 'ec2')!; + expect(ec2Bucket.recs.find((rec) => rec.id === 'a1-partial')).toMatchObject({ + id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100, + }); + expect(ec2Bucket.recs.find((rec) => rec.id === 'a2-no')).toMatchObject({ + id: 'a2-no', payment: 'no-upfront', upfront_cost: 0, monthly_cost: 200, + }); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const ec2Body = (api.executePurchase as jest.Mock).mock.calls + .map(([body]) => body as Array>) + .find((body) => body.some((rec) => rec['service'] === 'ec2'))!; + expect(ec2Body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 2000, monthly_cost: 100 }), + expect.objectContaining({ id: 'a2-no', payment: 'no-upfront', upfront_cost: 0, monthly_cost: 200 }), + ])); + }); + + test('fan-out renders an absent current payment as a disabled unavailable select', 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: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'rds-3-no', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'no-upfront', + count: 2, upfront_cost: 0, monthly_cost: 800, savings: 500, + }, + ]; + (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-1-all', 'rds-3-no'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('rds'))!; + const select = rdsSection.querySelector('.fanout-bucket-payment')!; + expect(select.disabled).toBe(true); + expect(Array.from(select.options)).toHaveLength(1); + expect(select.options[0]).toMatchObject({ + textContent: 'Unavailable: no priced payment', disabled: true, selected: true, + }); + }); + + test('fan-out resolves a viable legacy sibling before rendering the bucket control', 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: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'rds-3-no', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'no-upfront', + count: 2, upfront_cost: 0, monthly_cost: 800, savings: 500, + }, + { + 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: '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-1-all', 'rds-3-no'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('rds'))!; + const select = rdsSection.querySelector('.fanout-bucket-payment')!; + expect(select.disabled).toBe(false); + expect(Array.from(select.options).map((option) => option.value)).toEqual(['all-upfront']); + expect(select.value).toBe('all-upfront'); + expect(select.closest('.fanout-bucket')!.querySelector('.fanout-bucket-totals')!.textContent).toContain('$6,000'); + }); + + test('fan-out restores unchanged state when an offered sibling disappears during edit', async () => { + const loadedRows: 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: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: 'rds-3-no', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'no-upfront', + count: 2, upfront_cost: 0, monthly_cost: 800, savings: 500, + }, + { + 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, + }, + { + id: 'rds-3-partial', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: 'partial-upfront', + count: 2, upfront_cost: 3000, monthly_cost: 100, savings: 900, + }, + ]; + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'all-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: loadedRows, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-1-all', 'rds-3-no'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((section) => section.textContent?.includes('rds'))!; + const paymentSelect = rdsSection.querySelector('.fanout-bucket-payment')!; + const totalsBefore = rdsSection.querySelector('.fanout-bucket-totals')!.textContent; + expect(paymentSelect.value).toBe('all-upfront'); + expect(Array.from(paymentSelect.options).map((option) => option.value)).toEqual(['all-upfront', 'partial-upfront']); + expect(totalsBefore).toContain('$6,000'); + + loadedRows.splice(loadedRows.findIndex((row) => row.id === 'rds-3-partial'), 1); + paymentSelect.value = 'partial-upfront'; + paymentSelect.dispatchEvent(new Event('change')); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })); + expect(paymentSelect.value).toBe('all-upfront'); + expect(rdsSection.querySelector('.fanout-bucket-totals')!.textContent).toBe(totalsBefore); + expect(getFanOutBuckets()!.find((bucket) => bucket.service === 'rds')!.recs[0]).toMatchObject({ + id: 'rds-3-all', payment: 'all-upfront', upfront_cost: 6000, monthly_cost: 0, + }); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const body = (api.executePurchase as jest.Mock).mock.calls + .map(([payload]) => payload as Array>) + .find((payload) => payload.some((rec) => rec['service'] === 'rds'))!; + expect(body).toEqual([expect.objectContaining({ + id: 'rds-3-all', payment: 'all-upfront', upfront_cost: 6000, monthly_cost: 0, + })]); + }); }); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 148b88e63..7aab4389f 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -3147,8 +3147,8 @@ describe('Bundle B: term-aware bucketing in the Purchase flow', () => { // that exercise the post-Purchase fan-out must explicitly select the // recs they want included in the target. const mixed = [ - { id: 'a', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, - { id: 'b', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 3, savings: 200, upfront_cost: 800 }, + { id: 'a', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 }, + { id: 'b', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 3, payment: 'all-upfront', savings: 200, upfront_cost: 800 }, ]; (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: mixed, regions: [] }); (state.getRecommendations as jest.Mock).mockReturnValue(mixed); @@ -3221,9 +3221,18 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' // to exercise the (override / no-override / multi-account / edit) // matrix. const setupMixedTermRecs = (recs: Array>): void => { + const loadedRecs = recs.flatMap((rec) => { + if (rec['provider'] !== 'aws' || rec['service'] !== 'ec2') return [rec]; + const payment = rec['payment'] ?? 'all-upfront'; + const variants = [ + { ...rec, id: `${String(rec['id'])}-partial`, payment: 'partial-upfront' }, + { ...rec, id: `${String(rec['id'])}-no`, payment: 'no-upfront' }, + ]; + return [{ ...rec, payment }, ...variants]; + }); (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); - (state.getRecommendations as jest.Mock).mockReturnValue(recs); - (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + (state.getRecommendations as jest.Mock).mockReturnValue(loadedRecs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(loadedRecs); (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); // #273: action buttons now require an explicit selection. Each fan-out // test in this suite is asserting bucket assembly, not the selection- @@ -3531,9 +3540,9 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' expect(bucket1yr.perRecPayments).toBeDefined(); const prp = bucket1yr.perRecPayments!; // h1 (acct-x) seeded from partial-upfront override. - expect(prp.get('h1')).toBe('partial-upfront'); + expect(prp.get('h1-partial')).toBe('partial-upfront'); // h2 (acct-y) seeded from no-upfront override. - expect(prp.get('h2')).toBe('no-upfront'); + expect(prp.get('h2-no')).toBe('no-upfront'); // Bucket-level payment falls back to toolbar (multi-account, no single override). expect(bucket1yr.paymentSource).toBe('toolbar'); @@ -3541,8 +3550,8 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' // Per-rec dropdowns must be rendered in the modal. const perRecSelects = document.querySelectorAll('.fanout-per-rec-payment'); expect(perRecSelects.length).toBeGreaterThanOrEqual(2); - const h1Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h1'); - const h2Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h2'); + const h1Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h1-partial'); + const h2Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h2-no'); expect(h1Select?.value).toBe('partial-upfront'); expect(h2Select?.value).toBe('no-upfront'); }); @@ -3582,16 +3591,19 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' const after = getFanOutBuckets(); const afterBucket1yr = after!.find((b) => b.term === 1)!; - expect(afterBucket1yr.perRecPayments!.get('i1')).toBe('no-upfront'); + expect(afterBucket1yr.perRecPayments!.get('i1-no')).toBe('no-upfront'); // i2 still follows the bucket default — absent from the override map. expect(afterBucket1yr.perRecPayments!.has('i2')).toBe(false); // Setting i1 back to the bucket default removes the override again so the // row resumes tracking the bucket-level dropdown. - i1Select.value = 'all-upfront'; - i1Select.dispatchEvent(new Event('change')); + const liveI1Select = Array.from( + document.querySelectorAll('.fanout-per-rec-payment'), + ).find((s) => s.dataset['recId'] === 'i1-no')!; + liveI1Select.value = '__fanout-bucket-default__'; + liveI1Select.dispatchEvent(new Event('change')); const reset = getFanOutBuckets(); - expect(reset!.find((b) => b.term === 1)!.perRecPayments!.has('i1')).toBe(false); + expect(reset!.find((b) => b.term === 1)!.perRecPayments!.has('i1-no')).toBe(false); }); // Issue #197 regression (CR #838): the bucket-level Payment dropdown must @@ -3644,10 +3656,10 @@ describe('Issue #111: per-bucket Payment seed from per-account service override' const perRecSelects = Array.from( document.querySelectorAll('.fanout-per-rec-payment'), ); - const j1Select = perRecSelects.find((s) => s.dataset['recId'] === 'j1'); - const j2Select = perRecSelects.find((s) => s.dataset['recId'] === 'j2'); - expect(j1Select?.value).toBe('no-upfront'); - expect(j2Select?.value).toBe('no-upfront'); + const j1Select = perRecSelects.find((s) => s.dataset['recId'] === 'j1-no'); + const j2Select = perRecSelects.find((s) => s.dataset['recId'] === 'j2-no'); + expect(j1Select?.value).toBe('__fanout-bucket-default__'); + expect(j2Select?.value).toBe('__fanout-bucket-default__'); }); }); @@ -3940,10 +3952,10 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { test('SP plan types + a non-SP rec produce one SP bucket + one EC2 bucket', async () => { const recs = [ - { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, - { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 150, upfront_cost: 600 }, - { id: 's3', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-ec2instance', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 200, upfront_cost: 800 }, - { id: 'e1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 50, upfront_cost: 300 }, + { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 }, + { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 150, upfront_cost: 600 }, + { id: 's3', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-ec2instance', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 200, upfront_cost: 800 }, + { id: 'e1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 300 }, ]; (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); (state.getRecommendations as jest.Mock).mockReturnValue(recs); @@ -3982,8 +3994,8 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { test('SP recs at different terms still split by term', async () => { const recs = [ - { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, - { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 3, savings: 200, upfront_cost: 800 }, + { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 }, + { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 3, payment: 'all-upfront', savings: 200, upfront_cost: 800 }, ]; (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); (state.getRecommendations as jest.Mock).mockReturnValue(recs); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 3ab55842f..cdf9b51ee 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -986,7 +986,12 @@ function loadedCellVariants(rec: LocalRecommendation): LocalRecommendation[] { // The scaled variant the modal would submit for (term, payment), or null when // no such row was loaded or it scales to zero units at the modal's capacity. -function pricedCellVariant(rec: LocalRecommendation, term: 1 | 3, payment: BulkPurchasePayment): LocalRecommendation | null { +function pricedCellVariant( + rec: LocalRecommendation, + term: 1 | 3, + payment: BulkPurchasePayment, + capacityPercent: number, +): LocalRecommendation | null { const v = loadedCellVariants(rec).find((c) => c.term === term && normalizeBulkPayment(c.payment) === payment); if (!v) return null; // `rec` reached the modal already scaled to currentPurchaseCapacityPercent: @@ -994,7 +999,7 @@ function pricedCellVariant(rec: LocalRecommendation, term: 1 | 3, payment: BulkP // rows. Only the rows read from state.getRecommendations() are unscaled, so // re-scaling the fallback push would halve count and cost a second time. if (v === rec) return v; - return scaleRecForCapacity(v, currentPurchaseCapacityPercent); + return scaleRecForCapacity(v, capacityPercent); } // Distinct terms actually loaded for rec's cell, ascending. Used to build @@ -1010,9 +1015,13 @@ function cellTermOptions(rec: LocalRecommendation): Array<1 | 3> { // Payment options for rec's cell at `term`, restricted to combinations that // were loaded and remain priced at the modal capacity (issue #1903). -function cellPaymentOptions(rec: LocalRecommendation, term: 1 | 3): BulkPurchasePayment[] { +function cellPaymentOptions( + rec: LocalRecommendation, + term: 1 | 3, + capacityPercent: number, +): BulkPurchasePayment[] { return paymentOptionsFor(rec.provider as CompatProvider, rec.service, term).filter((p) => - pricedCellVariant(rec, term, p as BulkPurchasePayment) !== null, + pricedCellVariant(rec, term, p as BulkPurchasePayment, capacityPercent) !== null, ) as BulkPurchasePayment[]; } @@ -4145,7 +4154,13 @@ let currentFanOutBuckets: FanOutBucket[] | null = null; // check (renderFanOutBucketSection); submit and totals must agree with it // (issue #1904) so a bucket the UI marks skipped is never posted. function isSubmittableBucket(b: FanOutBucket): boolean { - return isBucketPaymentCompatible(b.recs, b.payment); + if (b.recs.length === 0) return false; + return b.recs.every((rec) => { + const effectivePayment = b.perRecPayments?.get(rec.id) ?? b.payment; + const actualPayment = normalizeBulkPayment(rec.payment); + return actualPayment === effectivePayment + && isPaymentSupported(rec.provider as CompatProvider, rec.service, rec.term as 1 | 3, effectivePayment); + }); } export function getFanOutBuckets(): FanOutBucket[] | null { @@ -4295,40 +4310,48 @@ async function openFanOutModal( // rec slugs on recs[].service are what the backend sees. const bucketService = isSavingsPlanService(r.service) ? SAVINGS_PLANS_BUCKET_KEY : r.service; - // Issue #197: for multi-account buckets, build a per-rec payment - // map seeded from each rec's account override (when available and - // supported), falling back to the bucket-level payment. This lets - // each account's payment policy apply inside a mixed-account bucket. + let resolvedRecs = recs; const distinctAccountIDs = new Set(recs.map((rec) => rec.cloud_account_id).filter(Boolean)); + let resolvedPayment = seed.payment; + let resolvedPaymentSource: 'override' | 'toolbar' = seed.source; let perRecPayments: Map | undefined; if (distinctAccountIDs.size > 1) { perRecPayments = new Map(); const bucketPayment = seed.payment; - for (const rec of recs) { - let recPayment: BulkPurchasePayment = bucketPayment; - if (rec.cloud_account_id) { - const overrides = overridesByAccount.get(rec.cloud_account_id); - if (overrides) { - const recTerm = rec.term as 1 | 3; - const match = overrides.find( - (o) => o.provider === (rec.provider as CompatProvider) && o.service === rec.service, - ); - const overridePayment = normalizeBulkPayment(match?.payment); - if ( - overridePayment - && isPaymentSupported(rec.provider as CompatProvider, rec.service, recTerm, overridePayment) - ) { - recPayment = overridePayment; - } + const resolved = recs.map((rec) => resolvePerRecPaymentSeed(rec, overridesByAccount, toolbar.capacity)); + if (resolved.every((value) => value !== null)) { + resolvedRecs = resolved.map((value) => value!.variant); + for (const value of resolved) { + const resolvedSeed = value!; + const resolvedPaymentForMap = normalizeBulkPayment(resolvedSeed.payment); + if (resolvedPaymentForMap && (resolvedSeed.source === 'override' || resolvedPaymentForMap !== bucketPayment)) { + perRecPayments.set(resolvedSeed.variant.id, resolvedPaymentForMap); } } - // Only record an explicit override; recs that match the bucket - // default are intentionally left out of the map so they keep - // following the bucket-level dropdown via the `?? b.payment` - // fallback on the execute path. Eagerly populating every rec would - // make the bucket-level control a no-op for unedited rows. - if (recPayment !== bucketPayment) { - perRecPayments.set(rec.id, recPayment); + } else { + resolvedRecs = recs; + } + } else { + const candidates: BulkPurchasePayment[] = [ + seed.payment, + ...recs + .map((rec) => normalizeBulkPayment(rec.payment)) + .filter((payment): payment is BulkPurchasePayment => payment !== null), + toolbar.payment, + ...paymentOptionsFor(r.provider as CompatProvider, r.service, r.term as 1 | 3) + .map((payment) => normalizeBulkPayment(payment)) + .filter((payment): payment is BulkPurchasePayment => payment !== null), + ].filter((payment, index, all) => + all.indexOf(payment) === index + && isPaymentSupported(r.provider as CompatProvider, r.service, r.term as 1 | 3, payment), + ); + for (const candidate of candidates) { + const resolved = recs.map((rec) => pricedCellVariant(rec, r.term as 1 | 3, candidate, toolbar.capacity)); + if (resolved.every((value) => value !== null)) { + resolvedRecs = resolved.map((value) => value!); + resolvedPayment = candidate; + resolvedPaymentSource = candidate === seed.payment ? seed.source : 'toolbar'; + break; } } } @@ -4340,10 +4363,10 @@ async function openFanOutModal( // the term from the bucket itself rather than from the dropped // toolbar override. term: r.term as 1 | 3, - payment: seed.payment, - paymentSource: seed.source, + payment: resolvedPayment, + paymentSource: resolvedPaymentSource, capacityPercent: toolbar.capacity, - recs, + recs: resolvedRecs, perRecPayments, }; }); @@ -4491,47 +4514,60 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { paymentLabel.appendChild(document.createTextNode('Payment: ')); const paymentSelect = document.createElement('select'); paymentSelect.className = 'fanout-bucket-payment'; - for (const opt of paymentOptionsFor(b.provider, b.service, b.term)) { + const purchasePending = document.getElementById('execute-purchase-btn')?.dataset['submitting'] === 'true'; + const inheritingRows = b.recs.filter((rec) => !b.perRecPayments?.has(rec.id)); + const bucketOptions = (inheritingRows[0] ? cellPaymentOptions(inheritingRows[0], b.term, b.capacityPercent) : []) + .filter((payment) => inheritingRows.every((rec) => + cellPaymentOptions(rec, b.term, b.capacityPercent).includes(payment), + )); + const hasCurrentOption = bucketOptions.includes(b.payment); + let unavailableOption: HTMLOptionElement | undefined; + if (!hasCurrentOption) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'Unavailable: no priced payment'; + option.selected = true; + option.disabled = true; + paymentSelect.appendChild(option); + unavailableOption = option; + } + for (const opt of bucketOptions) { const option = document.createElement('option'); option.value = opt; option.textContent = opt; - if (opt === b.payment) option.selected = true; + option.selected = opt === b.payment; paymentSelect.appendChild(option); } + if (unavailableOption) { + unavailableOption.selected = true; + paymentSelect.value = ''; + } + paymentSelect.disabled = purchasePending || bucketOptions.length === 0 || Boolean(b.perRecPayments && inheritingRows.length === 0); paymentSelect.addEventListener('change', () => { const next = paymentSelect.value as FanOutBucket['payment']; - // Find this bucket in module state by reference equality on the - // recs array (the recs array is preserved across the b ↔ - // currentFanOutBuckets[i] mapping; identity comparison is safe). - if (currentFanOutBuckets) { - const idx = currentFanOutBuckets.findIndex((cb) => cb.recs === b.recs); - if (idx >= 0) { - currentFanOutBuckets[idx]!.payment = next; - } + const replacements: Array = b.recs.map((rec) => { + if (b.perRecPayments?.has(rec.id)) return rec; + const replacement = pricedCellVariant(rec, b.term, next, b.capacityPercent); + return replacement ? { ...replacement, payment: next } : null; + }); + if (replacements.some((replacement) => replacement === null)) { + showToast({ + message: `No priced ${next} option is available for every inherited row at ${b.capacityPercent}% capacity.`, + kind: 'warning', + }); + paymentSelect.value = hasCurrentOption ? b.payment : ''; + return; } + b.recs = replacements.map((replacement) => replacement!); b.payment = next; - renderStatus(); - // Issue #1904: a payment fix here can move this bucket in or out of the - // submittable set, so the header's email count, skipped note, totals, - // and Execute-enabled state must follow immediately. refreshFanOutSummary(); - // Re-sync any visible per-rec selects whose ids are NOT explicit - // overrides: those rows follow the bucket default, so their displayed - // value must track the new bucket payment. Rows with an explicit - // override (present in perRecPayments) keep their own value. - if (b.perRecPayments) { - const perRecSelects = section.querySelectorAll('.fanout-per-rec-payment'); - perRecSelects.forEach((sel) => { - const recId = sel.dataset['recId']; - if (!recId || b.perRecPayments!.has(recId)) return; - // Only re-sync when this rec actually supports the new payment. - // Per-rec options derive from rec.service, which can differ from - // b.service in mixed-SP buckets; skip rows where `next` isn't an - // option so the displayed value never diverges from what posts. - const supported = Array.from(sel.options).some((o) => o.value === next); - if (supported) sel.value = next; - }); - } + const expanded = Array.from(section.querySelectorAll('details')).map((details) => details.open); + const refreshed = renderFanOutBucketSection(b); + refreshed.querySelectorAll('details').forEach((details, index) => { + details.open = expanded[index] ?? false; + }); + section.replaceWith(refreshed); + refreshed.querySelector('.fanout-bucket-payment')?.focus(); }); paymentLabel.appendChild(paymentSelect); paymentRow.appendChild(paymentLabel); @@ -4548,6 +4584,7 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { // independently. The bucket-level dropdown above still acts as a fallback // default but is labelled to make the per-rec row the primary surface. if (b.perRecPayments) { + const paymentMap = b.perRecPayments; const perRecNote = document.createElement('p'); perRecNote.className = 'fanout-per-rec-note'; perRecNote.textContent = 'Multi-account bucket: each commitment can use its own payment option.'; @@ -4556,7 +4593,10 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { const perRecList = document.createElement('ul'); perRecList.className = 'fanout-per-rec-list'; for (const rec of b.recs) { - const currentPayment = b.perRecPayments.get(rec.id) ?? b.payment; + const explicitPayment = paymentMap.get(rec.id); + const currentPayment = explicitPayment ?? b.payment; + const bucketDefaultValue = '__fanout-bucket-default__'; + const rowOptions = cellPaymentOptions(rec, b.term, b.capacityPercent); const li = document.createElement('li'); li.className = 'fanout-per-rec-item'; @@ -4569,34 +4609,61 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { const recSelect = document.createElement('select'); recSelect.className = 'fanout-per-rec-payment'; recSelect.dataset['recId'] = rec.id; - for (const opt of paymentOptionsFor(b.provider, rec.service, b.term)) { + const defaultAvailable = rowOptions.includes(b.payment); + const currentAvailable = rowOptions.includes(currentPayment); + const inheritOption = document.createElement('option'); + inheritOption.value = bucketDefaultValue; + inheritOption.textContent = `Use bucket default (${b.payment})`; + inheritOption.selected = explicitPayment === undefined && defaultAvailable; + inheritOption.disabled = !defaultAvailable; + recSelect.appendChild(inheritOption); + for (const opt of rowOptions) { const option = document.createElement('option'); option.value = opt; option.textContent = opt; - if (opt === currentPayment) option.selected = true; + if (explicitPayment !== undefined && opt === currentPayment) option.selected = true; recSelect.appendChild(option); } + if (!currentAvailable) { + const unavailableOption = document.createElement('option'); + unavailableOption.value = ''; + unavailableOption.textContent = 'Unavailable: no priced payment'; + unavailableOption.disabled = true; + unavailableOption.selected = true; + recSelect.prepend(unavailableOption); + } + recSelect.disabled = purchasePending || rowOptions.length === 0; + const renderedValue = recSelect.value; recSelect.addEventListener('change', () => { - const next = recSelect.value as BulkPurchasePayment; - // Keep perRecPayments as the explicit-override set: when the user - // picks the current bucket default, drop the entry so the row tracks - // future bucket-level changes again; otherwise record the override. - const applyToMap = (map: Map | undefined): void => { - if (!map) return; - if (next === b.payment) { - map.delete(rec.id); - } else { - map.set(rec.id, next); - } - }; - // Update module state and the local bucket reference. - if (currentFanOutBuckets) { - const idx = currentFanOutBuckets.findIndex((cb) => cb.recs === b.recs); - if (idx >= 0) { - applyToMap(currentFanOutBuckets[idx]!.perRecPayments); - } + if (recSelect.disabled || document.getElementById('execute-purchase-btn')?.dataset['submitting'] === 'true') return; + const inherit = recSelect.value === bucketDefaultValue; + const next = (inherit ? b.payment : recSelect.value) as BulkPurchasePayment; + const replacement = pricedCellVariant(rec, b.term, next, b.capacityPercent); + if (!replacement) { + showToast({ + message: `No priced ${next} option is available for this commitment at ${b.capacityPercent}% capacity.`, + kind: 'warning', + }); + recSelect.value = renderedValue; + return; + } + const updated = { ...replacement, payment: next }; + const index = b.recs.indexOf(rec); + if (index < 0) return; + b.recs[index] = updated; + if (inherit) paymentMap.delete(rec.id); + else { + paymentMap.delete(rec.id); + paymentMap.set(updated.id, next); } - applyToMap(b.perRecPayments); + refreshFanOutSummary(); + const expanded = Array.from(section.querySelectorAll('details')).map((details) => details.open); + const refreshed = renderFanOutBucketSection(b); + refreshed.querySelectorAll('details').forEach((details, detailIndex) => { + details.open = expanded[detailIndex] ?? false; + }); + section.replaceWith(refreshed); + refreshed.querySelector(`[data-rec-id="${updated.id}"]`)?.focus(); }); li.appendChild(recSelect); @@ -4970,6 +5037,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { function resolvePerRecPaymentSeed( rec: LocalRecommendation, overridesByAccount: Map, + capacityPercent: number, ): { payment: CompatPayment; source: 'override' | 'rec' | 'fallback'; variant: LocalRecommendation } | null { const provider = rec.provider as CompatProvider; const term = rec.term as 1 | 3; @@ -4985,7 +5053,7 @@ function resolvePerRecPaymentSeed( // row's payment without the matching price. const overridePayment = normalizeBulkPayment(match?.payment); if (overridePayment && isPaymentSupported(provider, rec.service, term, overridePayment)) { - const variant = pricedCellVariant(rec, term, overridePayment); + const variant = pricedCellVariant(rec, term, overridePayment, capacityPercent); if (variant) return { payment: overridePayment, source: 'override', variant }; } } @@ -4996,14 +5064,14 @@ function resolvePerRecPaymentSeed( return { payment: ownPayment, source: 'rec', variant: rec }; } - const options = cellPaymentOptions(rec, term); + const options = cellPaymentOptions(rec, term, capacityPercent); const preferred = normalizeBulkPayment(cachedGlobalDefaultPayment); if (preferred && options.includes(preferred)) { options.splice(options.indexOf(preferred), 1); options.unshift(preferred); } for (const payment of options) { - const variant = pricedCellVariant(rec, term, payment); + const variant = pricedCellVariant(rec, term, payment, capacityPercent); if (variant) return { payment, source: 'fallback', variant }; } return null; @@ -5090,7 +5158,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[], if (currentPurchaseRecommendations !== pendingRows) return; - const seeds = recommendations.map((r) => resolvePerRecPaymentSeed(r, overridesByAccount)); + const seeds = recommendations.map((r) => resolvePerRecPaymentSeed(r, overridesByAccount, capacityPercent)); const resolvedSeeds = seeds.filter((seed) => seed !== null); currentPurchaseRecommendations = resolvedSeeds.map((seed) => ({ ...seed.variant, payment: seed.payment })); checkedPurchaseIndices = new Set(currentPurchaseRecommendations.map((_, i) => i)); @@ -5541,7 +5609,7 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | const paymentCell = document.createElement('td'); const paymentSelect = document.createElement('select'); paymentSelect.className = 'purchase-row-payment'; - rebuildPaymentOptions(paymentSelect, cellPaymentOptions(rec, rec.term as 1 | 3), (rec.payment ?? '') as BulkPurchasePayment | ''); + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(rec, rec.term as 1 | 3, currentPurchaseCapacityPercent), (rec.payment ?? '') as BulkPurchasePayment | ''); paymentCell.appendChild(paymentSelect); if (paymentSource === 'override') { const sourceNote = document.createElement('span'); @@ -5574,7 +5642,7 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | const payment = paymentSelect.value as BulkPurchasePayment; const live = currentPurchaseRecommendations[idx]; if (!live) return; - const variant = pricedCellVariant(live, term, payment); + const variant = pricedCellVariant(live, term, payment, currentPurchaseCapacityPercent); if (!variant) { // Reachable: a sibling variant with a smaller count can floor to 0 at // the modal's capacity. Keep the priced rec and put the selects back. @@ -5583,7 +5651,7 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | kind: 'warning', }); termSelect.value = String(live.term); - rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, live.term as 1 | 3), (live.payment ?? '') as BulkPurchasePayment | ''); + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, live.term as 1 | 3, currentPurchaseCapacityPercent), (live.payment ?? '') as BulkPurchasePayment | ''); return; } currentPurchaseRecommendations[idx] = { ...variant, payment }; @@ -5601,7 +5669,7 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | // Rebuild this row's payment options for the new term before applying — // applyVariantChange reads paymentSelect.value, so it must already // reflect the new term's loaded options. - rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, newTerm), (live.payment ?? '') as BulkPurchasePayment | ''); + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, newTerm, currentPurchaseCapacityPercent), (live.payment ?? '') as BulkPurchasePayment | ''); applyVariantChange('.purchase-row-term'); }); From 6932db949210897a5327bc9e785f1d9ea1c0595e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 15 Sep 2026 01:14:03 +0200 Subject: [PATCH 2/3] fix(frontend): keep fan-out selection and submission consistent Exclude a whole bucket when a selected priced variant becomes unavailable at its capacity. Restore payment controls changed while submission is pending so the preview stays consistent with the captured request. Regressions reproduce unavailable-bucket inclusion and independently exercise pending bucket and row edits through the real submit handler. --- .../__tests__/purchase-modal-submit.test.ts | 177 ++++++++++++++++++ frontend/src/recommendations.ts | 17 +- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 474c2e469..92a79e88b 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -1085,6 +1085,111 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { expect(executeBtn.dataset['submitting']).toBeUndefined(); }); + test('pending fan-out bucket payment restores its value and preserves the captured request', async () => { + const rows: LocalRecommendation[] = [ + ...buildRows(), + { + id: 'rds-3', 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: 3000, monthly_cost: 0, savings: 500, + }, + ]; + const request = deferred>>(); + (api.executePurchase as jest.Mock).mockReturnValue(request.promise); + (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(['v-1-all', 'rds-3'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + const paymentSelect = document.querySelector('.fanout-bucket-payment')!; + const beforeValue = paymentSelect.value; + const alternate = Array.from(paymentSelect.options).find((option) => !option.disabled && option.value !== beforeValue)!; + expect(alternate).toBeTruthy(); + executeBtn.click(); + await flush(); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(2); + expect(executeBtn.dataset['submitting']).toBe('true'); + const callsBefore = (api.executePurchase as jest.Mock).mock.calls + .map(([payload, capacity, mode]) => [JSON.parse(JSON.stringify(payload)), capacity, mode]); + const summaryBefore = document.getElementById('fanout-summary')!.textContent; + paymentSelect.value = alternate.value; + expect(paymentSelect.value).not.toBe(beforeValue); + paymentSelect.dispatchEvent(new Event('change')); + expect(paymentSelect.value).toBe(beforeValue); + expect(document.getElementById('fanout-summary')!.textContent).toBe(summaryBefore); + const livePaymentSelect = document.querySelector('.fanout-bucket-payment')!; + expect(livePaymentSelect.isConnected).toBe(true); + expect(livePaymentSelect.value).toBe(beforeValue); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(2); + expect((api.executePurchase as jest.Mock).mock.calls.map(([payload, capacity, mode]) => [JSON.parse(JSON.stringify(payload)), capacity, mode])) + .toEqual(callsBefore); + request.resolve({ execution_id: 'pending-bucket', status: 'pending' }); + await flush(); + }); + + test('pending fan-out per-row payment restores its value and preserves the captured request', async () => { + const rows: LocalRecommendation[] = [ + ...(['a1', 'a2'] as const).flatMap((account) => [ + { + id: `${account}-all`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: `${account}-partial`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'partial-upfront' as const, + count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + ]), + { + id: 'rds-3', provider: 'aws' as const, cloud_account_id: 'a3', service: 'rds', region: 'us-east-1', + resource_type: 'db.r5.large', term: 3 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + const request = deferred>>(); + (api.executePurchase as jest.Mock).mockReturnValue(request.promise); + (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(['a1-all', 'a2-all', 'rds-3'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + const rowSelect = document.querySelector('.fanout-per-rec-payment')!; + const beforeValue = rowSelect.value; + const alternate = Array.from(rowSelect.options).find((option) => !option.disabled && option.value !== beforeValue)!; + expect(alternate).toBeTruthy(); + executeBtn.click(); + await flush(); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(2); + expect(executeBtn.dataset['submitting']).toBe('true'); + const callsBefore = (api.executePurchase as jest.Mock).mock.calls + .map(([payload, capacity, mode]) => [JSON.parse(JSON.stringify(payload)), capacity, mode]); + const summaryBefore = document.getElementById('fanout-summary')!.textContent; + rowSelect.value = alternate.value; + expect(rowSelect.value).not.toBe(beforeValue); + rowSelect.dispatchEvent(new Event('change')); + expect(rowSelect.value).toBe(beforeValue); + expect(document.getElementById('fanout-summary')!.textContent).toBe(summaryBefore); + const liveRowSelect = document.querySelector('.fanout-per-rec-payment')!; + expect(liveRowSelect.isConnected).toBe(true); + expect(liveRowSelect.value).toBe(beforeValue); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(2); + expect((api.executePurchase as jest.Mock).mock.calls.map(([payload, capacity, mode]) => [JSON.parse(JSON.stringify(payload)), capacity, mode])) + .toEqual(callsBefore); + request.resolve({ execution_id: 'pending-row', status: 'pending' }); + await flush(); + }); + test('fan-out clears submitting state when result processing throws', async () => { const rows = buildFanOutRows().map((row) => row.service === 'rds' ? { ...row, payment: 'partial-upfront' as const, upfront_cost: 1000 } @@ -1816,6 +1921,78 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { ])); }); + test('fan-out skips a zero-priced current bucket and restores native row edits while execute is pending', async () => { + const loadedRows: LocalRecommendation[] = [ + ...(['a1', 'a2'] as const).flatMap((account) => [ + { + id: `${account}-all`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 2000, monthly_cost: 0, savings: 500, + }, + { + id: `${account}-partial`, provider: 'aws' as const, cloud_account_id: account, service: 'ec2', region: 'us-east-1', + resource_type: 'm5.large', term: 1 as const, payment: 'partial-upfront' as const, + count: 2, upfront_cost: 1000, monthly_cost: 50, savings: 450, + }, + ]), + { + id: 'rds-3-all', provider: 'aws' as const, cloud_account_id: 'a3', service: 'rds', region: 'us-east-1', + resource_type: 'db.r5.large', term: 3 as const, payment: 'all-upfront' as const, + count: 2, upfront_cost: 3000, monthly_cost: 0, savings: 500, + }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'all-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: loadedRows, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(loadedRows); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['a1-all', 'a2-all', 'rds-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + let section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + const a1 = section.querySelector('[data-rec-id="a1-all"]')!; + a1.value = 'partial-upfront'; + a1.dispatchEvent(new Event('change')); + section = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((candidate) => candidate.textContent?.includes('ec2'))!; + const a2 = section.querySelector('[data-rec-id="a2-all"]')!; + loadedRows.find((row) => row.id === 'a1-partial')!.count = 0; + a2.value = 'partial-upfront'; + a2.dispatchEvent(new Event('change')); + + expect(getFanOutBuckets()!.some((bucket) => bucket.service === 'ec2')).toBe(false); + expect(document.querySelector('.fanout-bucket-error')?.textContent) + .toBe('A selected payment option is unavailable at 50% capacity. This bucket will be skipped.'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('$1,500'); + const execute = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + const pending = deferred(); + (api.executePurchase as jest.Mock).mockReturnValueOnce(pending.promise); + const summaryBefore = document.getElementById('fanout-summary')!.textContent; + execute.click(); + await flush(); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(1); + const captured = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + expect(captured).toEqual([expect.objectContaining({ + id: 'rds-3-all', service: 'rds', payment: 'all-upfront', count: 1, recommended_count: 2, upfront_cost: 1500, + })]); + const liveRow = document.querySelector('.fanout-bucket [data-rec-id="a1-partial"]')!; + const beforeValue = liveRow.value; + liveRow.value = 'all-upfront'; + expect(liveRow.value).not.toBe(beforeValue); + liveRow.dispatchEvent(new Event('change')); + expect(liveRow.value).toBe(beforeValue); + expect(document.getElementById('fanout-summary')!.textContent).toBe(summaryBefore); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(1); + execute.click(); + await flush(); + expect((api.executePurchase as jest.Mock).mock.calls).toHaveLength(1); + pending.resolve({ execution_id: 'pending-test', status: 'pending' }); + await flush(); + }); + test('fan-out skips an unresolved mixed legacy bucket while posting a valid bucket', async () => { const rows: LocalRecommendation[] = [ { diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index cdf9b51ee..010be6f2c 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4159,7 +4159,8 @@ function isSubmittableBucket(b: FanOutBucket): boolean { const effectivePayment = b.perRecPayments?.get(rec.id) ?? b.payment; const actualPayment = normalizeBulkPayment(rec.payment); return actualPayment === effectivePayment - && isPaymentSupported(rec.provider as CompatProvider, rec.service, rec.term as 1 | 3, effectivePayment); + && isPaymentSupported(rec.provider as CompatProvider, rec.service, rec.term as 1 | 3, effectivePayment) + && pricedCellVariant(rec, b.term, effectivePayment, b.capacityPercent) !== null; }); } @@ -4493,7 +4494,7 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { status.className = compat ? 'fanout-bucket-ok' : 'fanout-bucket-error'; status.textContent = compat ? `${b.capacityPercent}% capacity · ${b.term}yr · ${b.payment}` - : `Invalid combo: ${b.provider} / ${serviceLabel} doesn't support ${b.term}yr + ${b.payment}. This bucket will be skipped.`; + : `A selected payment option is unavailable at ${b.capacityPercent}% capacity. This bucket will be skipped.`; }; renderStatus(); section.appendChild(status); @@ -4543,7 +4544,12 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { paymentSelect.value = ''; } paymentSelect.disabled = purchasePending || bucketOptions.length === 0 || Boolean(b.perRecPayments && inheritingRows.length === 0); + const renderedValue = paymentSelect.value; paymentSelect.addEventListener('change', () => { + if (paymentSelect.disabled || document.getElementById('execute-purchase-btn')?.dataset['submitting'] === 'true') { + paymentSelect.value = renderedValue; + return; + } const next = paymentSelect.value as FanOutBucket['payment']; const replacements: Array = b.recs.map((rec) => { if (b.perRecPayments?.has(rec.id)) return rec; @@ -4555,7 +4561,7 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { message: `No priced ${next} option is available for every inherited row at ${b.capacityPercent}% capacity.`, kind: 'warning', }); - paymentSelect.value = hasCurrentOption ? b.payment : ''; + paymentSelect.value = renderedValue; return; } b.recs = replacements.map((replacement) => replacement!); @@ -4635,7 +4641,10 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { recSelect.disabled = purchasePending || rowOptions.length === 0; const renderedValue = recSelect.value; recSelect.addEventListener('change', () => { - if (recSelect.disabled || document.getElementById('execute-purchase-btn')?.dataset['submitting'] === 'true') return; + if (recSelect.disabled || document.getElementById('execute-purchase-btn')?.dataset['submitting'] === 'true') { + recSelect.value = renderedValue; + return; + } const inherit = recSelect.value === bucketDefaultValue; const next = (inherit ? b.payment : recSelect.value) as BulkPurchasePayment; const replacement = pricedCellVariant(rec, b.term, next, b.capacityPercent); From a730239186f1ab21f287048e9c95945aec309216 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 15 Sep 2026 01:49:00 +0200 Subject: [PATCH 3/3] fix(frontend): clarify unused fan-out bucket payment default Keep valid all-explicit buckets from displaying an unavailable-payment warning. Preserve control state and pricing, and verify live totals and submitted records in the existing mixed-account regression. --- frontend/src/__tests__/purchase-modal-submit.test.ts | 9 +++++++++ frontend/src/recommendations.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 92a79e88b..7ff1e2df1 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -1609,6 +1609,15 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { expect.objectContaining({ id: 'a1-partial', payment: 'partial-upfront', upfront_cost: 2000 }), expect.objectContaining({ id: 'a2-no', payment: 'no-upfront', upfront_cost: 0 }), ])); + const bucketSelect = document.querySelector('.fanout-bucket-payment')!; + expect(bucketSelect.isConnected).toBe(true); + expect(bucketSelect.disabled).toBe(true); + expect(bucketSelect.value).toBe(''); + expect(bucketSelect.selectedOptions[0]?.textContent).toBe('all-upfront (every row uses its own payment)'); + expect(document.querySelector('.fanout-bucket-error')).toBeNull(); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total commitments: 10'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total upfront: $8,000'); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Total savings / mo: $2,700'); (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); await flush(); const body = (api.executePurchase as jest.Mock).mock.calls diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 010be6f2c..519287dd6 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4526,7 +4526,9 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { if (!hasCurrentOption) { const option = document.createElement('option'); option.value = ''; - option.textContent = 'Unavailable: no priced payment'; + option.textContent = b.perRecPayments && inheritingRows.length === 0 + ? `${b.payment} (every row uses its own payment)` + : 'Unavailable: no priced payment'; option.selected = true; option.disabled = true; paymentSelect.appendChild(option);