fix: buyer can withdraw offer while waiting on seller

The withdraw form on /o/<id> was nested inside the buyer's
`can_act` (your-turn) block, so a buyer who'd just opened an offer
or whose counter was awaiting the seller's response saw the
"Waiting on the other party" panel with no way to back out — they
either had to wait for auto-expiry or message the seller.

Add a symmetric withdraw form inside the `is_open` waiting block,
gated on actor_party == 0 (viewer is the buyer). The lib already
permits withdraw at any non-terminal pre-accept state
(lib/offer.py withdraw_offer); only the UI was the bottleneck.

Sellers don't get a symmetric "pull out" here — they decline
instead, which lives in their can_act block.

Regression test test_buyer_sees_withdraw_button_while_waiting_on_seller
asserts the form action and "Withdraw offer" copy render on a
PENDING offer from the buyer's view.
This commit is contained in:
russell@unturf.com 2026-05-14 17:03:09 -04:00
parent f9216befd6
commit da79b11cae
No known key found for this signature in database
2 changed files with 55 additions and 0 deletions

View file

@ -96,6 +96,16 @@
They have <strong data-pay-deadline="{{ respond_deadline_timestamp_ms }}">{{ respond_deadline_human }}</strong> to respond, or this offer auto-expires.
{% endif %}
</p>
{# 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 %}
<form method="post" action="/o/{{ id }}/withdraw" class="offer-action-form" style="margin-top: var(--space-3);"
onsubmit="return confirm('Withdraw your offer? This ends the negotiation.');">
<input type="submit" class="mps-button" value="Withdraw offer" />
</form>
{% endif %}
</section>
{% endif %}

View file

@ -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