Repository navigation
fix(frontend): purchase modal Term and Payment selects mutate the rec without re-pricing #1903
Description
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.
Activity
fix(frontend): re-price purchase modal rows from the loaded variant o…
fix(frontend): re-price purchase modal rows from the loaded variant o…
cristim commentedon Sep 14, 2026
Merged: PR #2071 at596680d0350fb4f324120bde1fa338e66c62ee51 (reviewed head23b6dc27422b18471057b6c12cd66507af12a852).\n\nVerified: native macOS full frontend suite91/2944passed/1skipped, typecheck/lint/build and audit passed. Real Chrome fixture checks exercised Term/Payment changes and captured matching sibling IDs, quantities, details and prices in submitted requests. Pending edits stayed disabled and did not create duplicate submissions. Independent before/after checks failed on faulty baselines and passed on the final implementation, including identity selection and capacity-viable alternatives: #2071 (comment) .\n\nFresh-context Astra reviewed the full final diff with no confirmed findings, CodeRabbit completed a clean full five-file review, and all six exact-head CI workflows passed before normal merge. Post-merge runs are being monitored separately. This is local fixture evidence, not a claim of deployed/cloud purchase, authentication or email verification. Current backend already reprices from stored rows; the issue body describes historical backend behavior.\n\nThe issue was automatically closed by the merge. Separate follow-ups remain #2070 (fan-out repricing), LeanerCloud/cloud-commitments-platform#331 (modal lifecycle), LeanerCloud/cloud-commitments-platform#333 (account grouping), LeanerCloud/cloud-commitments-platform#334 (stored identity), LeanerCloud/cloud-commitments-platform#136 (capacity display percentage), and LeanerCloud/cloud-commitments-platform#332/#1865 (structure).
cristim commentedon Sep 14, 2026
Post-merge update: all nine workflows passed for merged commit 596680d. Read-only checks on all three dev deployments confirm the changed app.ts and recommendations.ts source matches that commit exactly. Cloud workflow health/smoke checks passed. Deployed Chrome navigation was blocked by the browser connector before page access; no deployed UI pass or real purchase is claimed. Full evidence and limitations: #2071 (comment) . Existing local Chrome and regression proof remains in the earlier verification comment.