fix: disable Add To Cart on product page when active cart is negotiated

The server-side guards on /cart/add and /cart/{id}/quantity stop the
exploit, but the buyer still saw an enabled "Add To Cart" button that
bounced them. Per fox: disable the button with a one-sentence caption
explaining why, instead of hiding it. A hidden button confuses users;
a disabled one with caption keeps the affordance visible and teaches
the user how to re-enable it (check out or save the current cart).

product.j2 now:
- Adds `disabled` to both Add To Cart buttons (physical + digital
  paths) when `request.active_cart and request.active_cart.is_negotiated`.
- Renders a `.product-add-disabled-note` paragraph beneath with the
  reason: "Your active cart is locked to an accepted offer/auction.
  Save or check out that cart before adding other items."

Token-driven CSS for the caption (no inline styles, no hardcoded
colors). The button keeps its mps-button styling — browser disabled
state is sufficient visual contrast.

Test test_product_page_disables_add_to_cart_on_negotiated_cart walks
the realistic flow: buyer accepts an offer on product A, then visits
product B (a separate buy-now product on the same shop) and sees the
button disabled with the caption.
This commit is contained in:
russell@unturf.com 2026-05-13 16:50:27 -04:00
parent cb9b3df0cf
commit 36bb6b2ea6
No known key found for this signature in database
3 changed files with 75 additions and 2 deletions

View file

@ -3189,6 +3189,13 @@ img.crypto-button-icon {
font-weight: var(--weight-bold, 700);
}
.product-add-disabled-note {
margin: var(--space-2, 8px) 0 0 0;
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #666);
max-width: 30em;
}
.cart-negotiation-deadline {
grid-column: 1 / -1;
margin: var(--space-3, 12px) 0 0 0;

View file

@ -201,6 +201,15 @@
{% endif %}
{% endif %}
{# Disable Add To Cart when the active cart is locked to an
accepted offer / auction. The cart total is overridden by the
negotiated amount regardless of quantity, so adding another
unit (same or different product) would let the buyer walk away
with more merch than they paid for. Disable + caption rather
than hiding the button so the affordance stays visible. #}
{% set cart_is_negotiated = request.active_cart and request.active_cart.is_negotiated %}
{% set cart_negotiation_kind = request.active_cart.negotiation_kind if cart_is_negotiated else "" %}
{% if product.is_physical %}
{# MPS-20 + MPS-21: physical Add To Cart only renders when
pricing_mode permits direct purchase (modes 0, 2, 4).
@ -212,8 +221,11 @@
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
<button type="submit" class="product-download-button mps-button"{% if cart_is_negotiated %} disabled{% endif %}>Add To Cart</button>
</form>
{% if cart_is_negotiated %}
<p class="product-add-disabled-note">Your active cart is locked to an accepted {{ cart_negotiation_kind }}. Save or check out that cart before adding other items.</p>
{% endif %}
{% else %}
<button class="product-download-button mps-button sold-out-button" disabled>Sold Out</button>
{% if request.shop.shop_locations.all()|length > 1 %}
@ -227,8 +239,11 @@
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
<button type="submit" class="product-download-button mps-button"{% if cart_is_negotiated %} disabled{% endif %}>Add To Cart</button>
</form>
{% if cart_is_negotiated %}
<p class="product-add-disabled-note">Your active cart is locked to an accepted {{ cart_negotiation_kind }}. Save or check out that cart before adding other items.</p>
{% endif %}
{% endif %}
{% endif %}

View file

@ -7090,6 +7090,57 @@ class TestOfferCheckout(_AuthenticatedBase):
self.dbsession.refresh(cart)
self.assertEqual(cart.get_product_quantity(product), 1)
def test_product_page_disables_add_to_cart_on_negotiated_cart(self):
"""When the active cart is locked to an accepted offer, the
product page renders Add To Cart with `disabled` and a one-line
caption explaining why. Per fox: disable, don't hide — the
affordance stays visible so the user knows the option exists
and what action will re-enable it.
The offer-only product itself doesn't have an Add To Cart button
(offer-only mode hides it). So we check a *second* buy-now
product on the same shop — the realistic flow: buyer accepted
an offer on product A, browses product B and tries to add it
normally.
"""
from ..models.product import Product
import json
offer_id = self._accepted_offer()
# _accepted_offer left user1 (seller) logged in. Add a second
# buy-now product to the same shop before the buyer logs in.
from ..models.offer import get_offer_by_id
offer = get_offer_by_id(self.dbsession, offer_id)
shop = offer.shop
other = Product(title="Buy now item", description="...")
other.shop = shop
other.price_in_cents = 500
other.is_physical = False
other.is_sellable = True
other.pricing_mode = 0
other.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"product": "pdf"}},
)
self.dbsession.add(other)
self.dbsession.flush()
other_id = other.uuid_str
other_slug = other.slug
transaction.commit()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
self.testapp.post(f"/o/{offer_id}/checkout", status=302)
body = self.testapp.get(
f"/p/{other_id}/{other_slug}", status=200
).body.decode()
self.assertIn("Add To Cart", body)
self.assertRegex(
body,
r'<button[^>]*\bdisabled\b[^>]*>Add To Cart</button>',
)
self.assertIn("locked to an accepted offer", body)
def test_negotiated_cart_refuses_quantity_bump(self):
"""Same defense for /cart/{id}/quantity — the buyer can't bump
quantity directly on the cart page either.