fix: offer checkout is one-shot; seller copy says so

Two coupled fixes on the accepted-offer flow:

1. Single redemption. offer_checkout was creating a fresh cart on
   every POST. While offer.state == ACCEPTED, a buyer could spawn N
   parallel carts on one offer; mark_paid is idempotent on the offer
   but the *other* carts still carried the override and could each
   complete checkout, double-charging the buyer. Now: if any
   cart_offer already exists for the offer, reuse that cart (and
   re-activate it). Only one cart_offer row can ever exist per offer.

2. Seller copy on the "Awaiting payment" panel said "they need to
   sign in and pay from this same page — send them the link", which
   implied the seller had to manually deliver the link. The system
   already emails the buyer on accept (send_offer_accepted_email is
   wired in views/offer.py for both auto-accept and manual paths).
   The copy now reflects that: "We emailed them a one-time checkout
   link — this offer can be redeemed only once." The shareable link
   stays as a fallback for if the buyer asks for it again.

Functional test test_offer_checkout_is_single_redemption asserts
three consecutive POSTs to /o/{id}/checkout redirect to the same
cart URL and produce exactly one cart_offer row. Existing
test_accepted_offer_seller_sees_pay_link_to_share extended to
assert the new copy ("emailed", "one-time checkout link").
This commit is contained in:
russell@unturf.com 2026-05-13 10:09:16 -04:00
parent 6827fb8ac9
commit 7dc82dda50
No known key found for this signature in database
3 changed files with 58 additions and 10 deletions

View file

@ -110,7 +110,8 @@
{% elif request.user %}
<section class="well offer-await-pay">
<h3 class="type-title">Awaiting payment</h3>
<p>You accepted <a href="/profile/{{ buyer_handle }}?shop={{ shop_id }}">{{ buyer_name }}</a>&rsquo;s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. They need to sign in and pay from this same page &mdash; send them the link:</p>
<p>You accepted <a href="/profile/{{ buyer_handle }}?shop={{ shop_id }}">{{ buyer_name }}</a>&rsquo;s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. We emailed them a one-time checkout link &mdash; this offer can be redeemed only once.</p>
<p class="offer-await-share-note">If they need it again, share this same page:</p>
<div class="offer-pay-link-row">
<input type="text" readonly class="offer-pay-link" value="{{ request.scheme }}://{{ request.host }}/o/{{ id }}" onclick="this.select();" />
<button type="button" class="mps-button-small js-only"

View file

@ -6844,9 +6844,42 @@ class TestOfferCheckout(_AuthenticatedBase):
self.assertIn("Awaiting payment", body)
self.assertIn("offer-pay-link", body)
self.assertIn("waiting on the buyer to pay $75.00", body)
# Auto-email language: seller is told the buyer was emailed a
# one-time link, not that they need to courier it themselves.
self.assertIn("emailed", body)
self.assertIn("one-time checkout link", body)
# Seller never sees the buyer's pay-now form (they cannot pay).
self.assertNotIn(f"/o/{offer_id}/checkout", body)
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
could spawn N carts on a single accepted offer and pay any one,
which leaves N-1 orphaned carts the buyer could also try to pay.
"""
offer_id = self._accepted_offer()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
first = self.testapp.post(f"/o/{offer_id}/checkout", status=302)
first_cart_url = first.location
second = self.testapp.post(f"/o/{offer_id}/checkout", status=302)
third = self.testapp.post(f"/o/{offer_id}/checkout", status=302)
self.assertEqual(first_cart_url, second.location)
self.assertEqual(first_cart_url, third.location)
# Exactly ONE cart_offer row exists for this offer.
from ..models.cart_offer import MpsCartOffer
from ..models.offer import get_offer_by_id
offer = get_offer_by_id(self.dbsession, offer_id)
rows = (
self.dbsession.query(MpsCartOffer)
.filter(MpsCartOffer.offer_id == offer.id)
.all()
)
self.assertEqual(len(rows), 1)
def test_accepted_offer_buyer_sees_pay_now(self):
offer_id = self._accepted_offer()
self.testapp.get("/log-out")

View file

@ -390,9 +390,15 @@ def offer_withdraw(request):
@view_config(route_name="offer_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def offer_checkout(request):
"""Buyer checkout for an accepted offer: build a fresh cart linked to
the offer and redirect into it. Cart total is overridden to the agreed
amount via the cart_offer association.
"""Buyer checkout for an accepted offer: build (or reuse) a cart
linked to the offer and redirect into it. Cart total is overridden
to the agreed amount via the cart_offer association.
Single-redemption: an accepted offer maps to at most ONE cart. If
the buyer revisits /o/{id}/checkout after a cart was already opened,
we redirect them into that same cart instead of spawning a fresh
one otherwise the buyer could create N parallel carts on a single
offer and try to pay each.
"""
from ..models.cart_offer import MpsCartOffer
from ..models.offer import OFFER_STATE_ACCEPTED
@ -407,16 +413,24 @@ def offer_checkout(request):
request.session.flash(("Only the buyer can check out.", "error"))
return HTTPFound(f"/o/{offer.uuid_str}")
# create_new_cart_for_user flips every other cart for this user+shop
# to inactive and activates the new one — so /cart resolves to it.
# (Setting cart.active = True by hand left two active carts and /cart
# would land on the old empty one.)
# Reuse-or-create. One offer → one cart_offer → one cart, ever.
existing = (
request.dbsession.query(MpsCartOffer)
.filter(MpsCartOffer.offer_id == offer.id)
.first()
)
if existing is not None:
cart = existing.cart
# Make sure this cart is active (the buyer's most recent
# interaction makes it the one /cart resolves to).
offer.shop.make_cart_active_for_user(request.user, cart)
return HTTPFound(f"/cart/{cart.uuid_str}")
# First-time checkout for this offer — spawn the cart.
cart = offer.shop.create_new_cart_for_user(request.user)
cart.add_product(offer.product)
request.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
request.dbsession.flush()
# Land directly in the cart by id — works regardless of which shop the
# request happens to be scoped to (SaaS domain vs the offer's shop).
return HTTPFound(f"/cart/{cart.uuid_str}")