Skip to content

fix(checkout): preserve locked unit count when switching products - #14196

Merged
strandhvilliam merged 2 commits into
mainfrom
detail/bug-fix/fix-checkout-preserve-locked-unit-count-when-switc-be989e
Sep 8, 2026
Merged

strandhvilliam merged 2 commits into
mainfrom
detail/bug-fix/fix-checkout-preserve-locked-unit-count-when-switc-be989e

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Summary

Related Issue: polarsource/feedback#463

Fixes a checkout bypass where switching a unit-priced product on a multi-product checkout link ignored the merchant's locked unit count (min_units/max_units), collapsing the quantity to the new product's minimum (typically 1) and letting the customer underpay.

What

  • CheckoutService._update_price (server/polar/checkout/service.py): the unit branch now mirrors the existing seat branch — captures the previous unit count, detects a min_units/max_units lock, preserves & clamps the unit count across product switches when unlocked, and passes checkout_min_units/checkout_max_units into _validate_unit_limits (kwargs that already existed but went unused here).
  • server/tests/checkout/test_service.py: adds two fixtures (product_unit_based_with_max, product_unit_based_with_min_max) and four TestUpdate tests mirroring the seat-switch coverage (test_switching_products_preserves_units, _clamps_units_to_new_bounds, _preserves_locked_units, _enforces_locked_units_on_override).
  • server/tests/fixtures/random_objects.py: create_checkout now accepts min_units/max_units, mirroring the existing min_seats/max_seats.

Why

On the embedded checkout, switching products submits only product_id (never units). The unit branch of _update_price always fell back to units = checkout_update.units or unit_price.get_minimum_purchasable_units() and called _validate_unit_limits without the checkout-level lock. A merchant who locked units via a checkout link (min_units == max_units == N, the shape _create_from_link produces) could have a customer switch to a cheaper unit product and pay for 1 unit instead of the locked N.

The sibling seat branch was fixed for this same bypass class in #13749, but the adjacent unit branch was missed — even though _validate_unit_limits already supported the lock kwargs. There is no downstream re-validation of the lock on the customer-reachable switch path, so _update_price is the only enforcement point.

How

Mirror the seat branch in shape: capture previous_units before resetting, branch on is_unit_count_locked, preserve-and-clamp across an unlocked switch, fall back to the previous or minimum count when locked, then call _validate_unit_limits with checkout_min_units/checkout_max_units. The trailing calculate_upfront_amount(..., units=units) already referenced the local units variable, so it picks up the corrected value with no further change.

Testing

  • Added four TestUpdate tests mirroring the existing seat-switch regression suite (preserve, clamp, locked-preserve, locked-enforce-on-override); all four pass.
  • Wider regression: TestUpdate + TestCreateUnitBasedCheckout (90 tests) and tests/checkout/test_endpoints.py (86 tests) pass; the full tests/checkout/ suite passes (465 tests).
  • Verified the bug report's exact reproduction scenario (locked units=5, switch to a cheaper 2000/unit product carrying only product_id) now preserves units=5 and computes amount=14500 instead of collapsing to units=1, amount=2000. This was checked with a throwaway local test asserting the old buggy behavior, which now fails with AssertionError: assert 5 == 1 — the throwaway test was not committed.
  • ruff format --check, ruff check, and mypy on the changed files pass; py_compile clean.

Checklist

  • This PR addresses a single concern (one bug fix, one feature, one refactor)
  • The diff is reasonably sized and easy to review
  • New functionality is covered by tests
  • Linting and type checking pass (uv run task lint && uv run task lint_types)
  • No unrelated changes or drive-by fixes are included

Automatic Fixes PRs can be configured here.

Review in cubic

@detail-app
detail-app Bot requested a review from strandhvilliam September 7, 2026 01:29
@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
orbit Ready Ready Preview Sep 8, 2026 7:53am UTC
polar-test Ready Ready Preview Sep 8, 2026 7:53am UTC

Request Review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

OpenAPI Changes

No changes detected in the OpenAPI schema.

Remove the product-switch override test (already enforced on any units
update) and the extra min/max fixture in favor of product_unit_based_with_min.

Co-authored-by: Villiam Strandh <villiam.strandh@outlook.com>

@strandhvilliam strandhvilliam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as seats, so checks out

@strandhvilliam
strandhvilliam added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit a205119 Sep 8, 2026
29 of 30 checks passed
@strandhvilliam
strandhvilliam deleted the detail/bug-fix/fix-checkout-preserve-locked-unit-count-when-switc-be989e branch September 8, 2026 08:17

This branch was successfully deployed

2 active deployments
Preview – polar-test — d50cc26a Deployed Sep 8, 2026 by vercel[bot]
Preview – orbit — d50cc26a Deployed Sep 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants