fix: lock negotiated cart quantity — refuse /cart/add + quantity bump
The cart override returns offer.current_amount_in_cents regardless
of line-item quantity. So a buyer who:
1. Got an offer accepted at $21 (list $42)
2. Hit /o/{id}/checkout → got a cart with 1 unit + cart_offer
3. Went back to the product page and clicked "Add to cart"
…would end up with a cart showing 2 units of a $42 product but
charged the single negotiated $21. Seller eats $84 of merch for $21.
Same hole on /cart/{id}/quantity — bumping quantity directly skipped
the override re-check.
Plug both:
- cart_add_product refuses when active_cart.is_negotiated. Flashes
"This cart is locked to your accepted offer/auction — save or
check out this cart before adding other items."
- cart_quantity_product refuses when cart.is_negotiated. Flashes
"Quantity is locked on an accepted offer/auction — the agreed
price is for one unit only."
Two regression tests cover the gap: one POSTs /cart/add of the same
product, one POSTs /cart/{id}/quantity setting quantity=2. Both
assert cart.get_product_quantity(product) stays at 1 and the
response flashes "locked".
UI tightening (hiding the add-to-cart button on the product page
when the active cart is already negotiated to a different product)
is a follow-up — this commit is the server-side defense.
This commit is contained in:
parent
527298874e
commit
cb9b3df0cf
2 changed files with 110 additions and 0 deletions
|
|
@ -7043,6 +7043,90 @@ class TestOfferCheckout(_AuthenticatedBase):
|
|||
self.dbsession.refresh(offer)
|
||||
self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
|
||||
|
||||
def test_negotiated_cart_refuses_add_product(self):
|
||||
"""A cart bound to an accepted offer must not accept additional
|
||||
product adds. Otherwise the buyer can walk away with N units at
|
||||
the single-unit negotiated price (the override total is constant
|
||||
regardless of line-item quantity).
|
||||
"""
|
||||
from ..models.cart import Cart
|
||||
from ..models.cart_offer import MpsCartOffer
|
||||
|
||||
offer_id = self._accepted_offer()
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
|
||||
# First the buyer pays for their offer — this creates a cart
|
||||
# with cart_offer linked.
|
||||
self.testapp.post(f"/o/{offer_id}/checkout", status=302)
|
||||
|
||||
# The cart has 1 unit of the offer's product.
|
||||
from ..models.user import get_user_by_email
|
||||
buyer = get_user_by_email(self.dbsession, self.user2_creds[0])
|
||||
match = [
|
||||
c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all()
|
||||
if c.cart_offers
|
||||
]
|
||||
self.assertEqual(len(match), 1)
|
||||
cart = match[0]
|
||||
product = cart.cart_offers[0].offer.product
|
||||
self.assertEqual(cart.get_product_quantity(product), 1)
|
||||
|
||||
# Now try to add the SAME product via /cart/add.
|
||||
csrf_token = self.get_csrf_token(product.shop.uuid_str)
|
||||
res = self.testapp.post(
|
||||
"/cart/add",
|
||||
{
|
||||
"product_id": product.id,
|
||||
"shop_id": product.shop.id,
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
flash_body = res.follow().body.decode()
|
||||
self.assertIn("locked", flash_body)
|
||||
|
||||
# Quantity unchanged in DB.
|
||||
self.dbsession.refresh(cart)
|
||||
self.assertEqual(cart.get_product_quantity(product), 1)
|
||||
|
||||
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.
|
||||
"""
|
||||
from ..models.cart import Cart
|
||||
|
||||
offer_id = self._accepted_offer()
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
self.testapp.post(f"/o/{offer_id}/checkout", status=302)
|
||||
|
||||
from ..models.user import get_user_by_email
|
||||
buyer = get_user_by_email(self.dbsession, self.user2_creds[0])
|
||||
cart = [
|
||||
c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all()
|
||||
if c.cart_offers
|
||||
][0]
|
||||
product = cart.cart_offers[0].offer.product
|
||||
|
||||
csrf_token = self.get_csrf_token(product.shop.uuid_str)
|
||||
res = self.testapp.post(
|
||||
f"/cart/{cart.uuid_str}/quantity",
|
||||
{
|
||||
"product_id": product.id,
|
||||
"quantity": "2",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
# Follow the full redirect chain until we land on a rendered page.
|
||||
while 300 <= res.status_int < 400:
|
||||
res = res.follow()
|
||||
flash_body = res.body.decode()
|
||||
self.assertIn("locked", flash_body)
|
||||
self.dbsession.refresh(cart)
|
||||
self.assertEqual(cart.get_product_quantity(product), 1)
|
||||
|
||||
def test_offer_checkout_is_single_redemption(self):
|
||||
"""Repeated POSTs to /o/{id}/checkout must reuse the same cart
|
||||
— one offer, one cart, one chance to redeem. Otherwise a buyer
|
||||
|
|
|
|||
|
|
@ -384,6 +384,18 @@ def cart_add_product(request):
|
|||
elif product.is_not_sellable:
|
||||
msg = ("that content is not for sale or purchase.", "error")
|
||||
|
||||
elif request.active_cart.is_negotiated:
|
||||
# A negotiated cart is single-purpose: one product, one unit,
|
||||
# one negotiated price. Bumping quantity or adding other items
|
||||
# would dilute the override (paying the agreed amount but
|
||||
# walking with extra units / extra products).
|
||||
kind = request.active_cart.negotiation_kind # "offer" | "auction"
|
||||
msg = (
|
||||
f"This cart is locked to your accepted {kind}. "
|
||||
f"Save or check out this cart before adding other items.",
|
||||
"error",
|
||||
)
|
||||
|
||||
else:
|
||||
msg = (f'You added "{product.title}" to your cart.', "success")
|
||||
request.active_cart.add_product(product)
|
||||
|
|
@ -486,6 +498,20 @@ def cart_quantity_product(request):
|
|||
("Refusing to add a product from another shop to cart.", "error")
|
||||
)
|
||||
return HTTPFound("/cart")
|
||||
elif cart.is_negotiated:
|
||||
# Negotiated carts (cart_offer / cart_auction) are locked to
|
||||
# one unit of the negotiated product. Bumping quantity would
|
||||
# let the buyer walk with N units at the single-unit negotiated
|
||||
# price.
|
||||
request.session.flash(
|
||||
(
|
||||
"Quantity is locked on an accepted "
|
||||
f"{cart.negotiation_kind} — the agreed price is for one "
|
||||
"unit only.",
|
||||
"error",
|
||||
)
|
||||
)
|
||||
return HTTPFound("/cart")
|
||||
else:
|
||||
msg = (
|
||||
f'You updated the quantity of "{product.title}" in the cart.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue