diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index 0f46c8aaa..c37ebbe80 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -1025,3 +1025,67 @@ describe('#1503 — payment-option coercion is disclosed in the purchase toast', expect(notice).toContain('Pay Monthly'); }); }); + +describe('handleExecutePurchase - missing payment option (#431)', () => { + beforeEach(() => { + jest.clearAllMocks(); + (recs.getFanOutBuckets as jest.Mock).mockReturnValue(null); + (plans.closePurchaseModal as jest.Mock).mockImplementation(() => undefined); + }); + + afterEach(() => { + document.body.textContent = ''; + }); + + test.each([ + ['undefined', undefined], + ['empty string', ''], + ])('refuses with an error toast and no POST when payment is %s', async (_label, payment) => { + const rec: Record = { ...buildMinimalRec() }; + if (payment === undefined) delete rec.payment; + else rec.payment = payment; + (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([rec]); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(api.executePurchase).not.toHaveBeenCalled(); + expect(confirmDialog).not.toHaveBeenCalled(); + expect(lastToastKind()).toBe('error'); + expect(lastToastMessage()).toContain('no payment option'); + expect(btn.disabled).toBe(false); + expect(plans.closePurchaseModal).not.toHaveBeenCalled(); + }); + + test('refuses the whole batch when only one of several recs lacks payment', async () => { + const missing: Record = { ...buildMinimalRec(), id: 'rec-2' }; + delete missing.payment; + (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([buildMinimalRec(), missing]); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(api.executePurchase).not.toHaveBeenCalled(); + expect(lastToastMessage()).toContain('1 of 2'); + }); + + test('a rec with a payment option is sent unchanged', async () => { + (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([ + { ...buildMinimalRec(), payment: 'no-upfront' }, + ]); + (api.executePurchase as jest.Mock).mockResolvedValue({ + execution_id: 'exec-1', + status: 'queued', + email_sent: true, + }); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const sent = (api.executePurchase as jest.Mock).mock.calls[0][0]; + expect(sent[0].payment).toBe('no-upfront'); + }); +}); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 85400162b..764a0f339 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -364,6 +364,21 @@ export async function handleExecutePurchase(): Promise { return; } + // Refuse before the confirm dialog and the POST when any rec has no payment + // option. Defaulting here would silently pick the most cash-intensive + // schedule on a money path (issue #431). + const recsWithPayment = localRecs.filter( + (r): r is typeof r & { payment: string } => !!r.payment, + ); + const missingCount = localRecs.length - recsWithPayment.length; + if (missingCount > 0) { + showToast({ + message: `Cannot submit: ${missingCount} of ${localRecs.length} selected recommendation${localRecs.length === 1 ? '' : 's'} ${missingCount === 1 ? 'has' : 'have'} no payment option. Choose a payment option for each row and try again.`, + kind: 'error', + }); + return; + } + // Read the execute mode set by the modal toggle (issue #289). // "direct" means the session has execute-any/execute-own and chose to // bypass approval; "" is the default approval-required path. @@ -407,16 +422,15 @@ export async function handleExecutePurchase(): Promise { // future additions) flow through unchanged. Only `payment`, `selected`, // and `purchased` are overridden: `payment` uses the user-edited value // (issue #111), and `selected`/`purchased` are forced to the canonical - // purchase-intent values. The `?? 'all-upfront'` on payment is defensive - // only — direct test-harness callers that bypass the modal may not set - // `payment`; the production path always does. Passing `details` ensures + // purchase-intent values. A missing `payment` was refused above, so it is + // never defaulted here. Passing `details` ensures // Windows EC2, dedicated-tenancy, AZ-scoped RIs, and non-default-engine // RDS/Cache recs reach the backend with the correct ServiceDetails payload // instead of falling back to Linux/regional/default (issue #597). - const apiRecs: api.Recommendation[] = localRecs.map((r) => ({ + const apiRecs: api.Recommendation[] = recsWithPayment.map((r) => ({ ...r, monthly_cost: r.monthly_cost ?? null, - payment: r.payment ?? 'all-upfront', + payment: r.payment, selected: true, purchased: false, }));