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:
parent
6827fb8ac9
commit
7dc82dda50
3 changed files with 58 additions and 10 deletions
|
|
@ -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>’s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. They need to sign in and pay from this same page — send them the link:</p>
|
||||
<p>You accepted <a href="/profile/{{ buyer_handle }}?shop={{ shop_id }}">{{ buyer_name }}</a>’s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. We emailed them a one-time checkout link — 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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue