Summary
In the purchase modal, changing a row's Term or Payment select mutates only the term and payment fields on the recommendation. The upfront, monthly cost and effective savings cells are one-shot text nodes that are never re-rendered, and the totals read the untouched cost fields. The POST therefore carries the new term with the old term's price, so the totals row, the approval email and the stored execution all record a price belonging to a term the user did not buy, and the backend's spend-cap check is evaluated against the mismatched pair.
Location
frontend/src/recommendations.ts:5412 at 3c0f8ac
Failure scenario
A 3yr all-upfront rec (upfront_cost $36,000, monthly_cost 0) is opened in the purchase modal. The user changes the row's Term select to 1yr. live.term becomes 1, rebuildPaymentOptions re-derives the payment list, and nothing else changes. The Upfront / Monthly Cost / Eff. Savings / Eff. % cells are static text nodes built once in renderPurchaseModalRow and are never re-rendered; updatePurchaseModalTotals reads the same untouched rec.upfront_cost / rec.savings. The POST therefore carries term: 1 with the 3-year price, and the totals row, the approval email and the stored execution all record a price belonging to a term the user did not buy. The Payment select (line 5430) has the same shape: flipping no-upfront to all-upfront leaves upfront_cost at the no-upfront value, so the modal shows "$0 upfront" for an all-upfront purchase. recTotalCommitment in internal/api/handler_purchases.go:2247 multiplies the submitted MonthlyCost by the submitted Term, so the MaxPurchaseAmount permission constraint is also evaluated against a mismatched pair.
Evidence
termSelect.addEventListener('change', () => {
const live = currentPurchaseRecommendations[idx];
if (!live) return;
const newTerm = parseInt(termSelect.value, 10) === 3 ? 3 : 1;
live.term = newTerm;
rebuildPaymentOptions(paymentSelect, live.provider as CompatProvider,
live.service, newTerm, (live.payment ?? '') as CompatPayment);
live.payment = paymentSelect.value;
});
Suggested fix
On a term/payment change, look up the matching variant from the loaded recommendation set (the API already fans out per (term, payment) cell) and swap the whole rec in, then re-render the row and the totals; if no matching variant exists, disable that option rather than keeping stale prices.
Found by the 2026-09-02 codebase audit, finding A12-001, reported by one reviewer and independently confirmed by a second. Full report: docs/audits/codebase-audit-2026-09-02.md.
Summary
In the purchase modal, changing a row's Term or Payment select mutates only the term and payment fields on the recommendation. The upfront, monthly cost and effective savings cells are one-shot text nodes that are never re-rendered, and the totals read the untouched cost fields. The POST therefore carries the new term with the old term's price, so the totals row, the approval email and the stored execution all record a price belonging to a term the user did not buy, and the backend's spend-cap check is evaluated against the mismatched pair.
Location
frontend/src/recommendations.ts:5412at 3c0f8acFailure scenario
A 3yr all-upfront rec (
upfront_cost$36,000,monthly_cost0) is opened in the purchase modal. The user changes the row's Term select to 1yr.live.termbecomes 1,rebuildPaymentOptionsre-derives the payment list, and nothing else changes. The Upfront / Monthly Cost / Eff. Savings / Eff. % cells are static text nodes built once inrenderPurchaseModalRowand are never re-rendered;updatePurchaseModalTotalsreads the same untouchedrec.upfront_cost/rec.savings. The POST therefore carriesterm: 1with the 3-year price, and the totals row, the approval email and the stored execution all record a price belonging to a term the user did not buy. The Payment select (line 5430) has the same shape: flippingno-upfronttoall-upfrontleavesupfront_costat the no-upfront value, so the modal shows "$0 upfront" for an all-upfront purchase.recTotalCommitmentininternal/api/handler_purchases.go:2247multiplies the submittedMonthlyCostby the submittedTerm, so theMaxPurchaseAmountpermission constraint is also evaluated against a mismatched pair.Evidence
Suggested fix
On a term/payment change, look up the matching variant from the loaded recommendation set (the API already fans out per
(term, payment)cell) and swap the whole rec in, then re-render the row and the totals; if no matching variant exists, disable that option rather than keeping stale prices.Found by the 2026-09-02 codebase audit, finding
A12-001, reported by one reviewer and independently confirmed by a second. Full report:docs/audits/codebase-audit-2026-09-02.md.