Skip to content

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

Closed

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

  1. added 2 commits that reference this issue on Sep 9, 2026
    34e4d00
    3e57db5
  2. cristim commented on Sep 14, 2026

    @cristim
    MemberAuthor

    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).

  3. cristim commented on Sep 14, 2026

    @cristim
    MemberAuthor

    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.

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