Skip to content

fix(frontend): purchase modal Term and Payment selects mutate the rec without re-pricing #1903

Description

@cristim

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions