diff --git a/make_post_sell/templates/offer.j2 b/make_post_sell/templates/offer.j2 index a847856..1f7a19c 100644 --- a/make_post_sell/templates/offer.j2 +++ b/make_post_sell/templates/offer.j2 @@ -96,6 +96,16 @@ They have {{ respond_deadline_human }} to respond, or this offer auto-expires. {% endif %}
+ {# Buyer can withdraw their offer at any non-terminal pre-accept + state — including when it's the seller's turn. The seller has + no symmetric "pull out" action; they decline instead, which + lives in their can_act block above. #} + {% if actor_party == 0 %} + + {% endif %} {% endif %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index b70f2aa..3d1f0f5 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -6943,6 +6943,51 @@ class TestOfferCheckout(_AuthenticatedBase): # itself carries the message now. self.assertNotIn("offer-state-notice", body) + def test_buyer_sees_withdraw_button_while_waiting_on_seller(self): + """When the offer is PENDING (seller's turn to respond), the + buyer should still be able to withdraw — the previous template + only rendered the withdraw form inside the buyer's `can_act` + block, so a buyer waiting for the seller had no way out except + wait for auto-expiry. + """ + from ..models.offer import ( + MpsOffer, OFFER_STATE_PENDING, now_timestamp, + ) + from ..models.product import Product + from ..models.user import get_or_create_user_by_email + + shop = self._create_shop_helper(user_creds=self.user1_creds) + shop.offer_enabled = True + product = Product(title="Negotiable", description="...") + product.shop = shop + product.price_in_cents = 10000 + product.is_physical = False + product.is_sellable = True + product.pricing_mode = 3 + self.dbsession.add(product) + self.dbsession.flush() + + buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com") + offer = MpsOffer( + product=product, shop=shop, buyer=buyer, + amount_in_cents=5000, + expires_timestamp=now_timestamp() + 86_400_000, + ) + offer.state = OFFER_STATE_PENDING # seller's turn + self.dbsession.add(offer) + self.dbsession.flush() + offer_id = offer.uuid_str + transaction.commit() + + # Log in as the buyer and visit the offer page. + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode() + # The buyer sees the waiting block AND a withdraw form. + self.assertIn("Waiting on the other party", body) + self.assertIn(f"/o/{offer_id}/withdraw", body) + self.assertIn("Withdraw offer", body) + def test_pending_offer_renders_respond_countdown(self): """A PENDING / COUNTERED offer carries a 'respond by' countdown (separate from the post-acceptance pay countdown). Both render