feat: buyer can cancel accepted offer; post-accept pay window

Two coupled additions to make-an-offer:

1. Buyer back-out after acceptance. New terminal state
   OFFER_STATE_BUYER_CANCELLED, route POST /o/{id}/cancel, and
   lib/offer.cancel_offer_after_accept(). Distinct from WITHDRAWN
   (which is pre-acceptance buyer pullout) so seller's inbox can
   visually distinguish "they ghosted after acceptance" from "they
   pulled it before I responded." Confirm dialog on the button —
   it's a destructive action.

2. Post-acceptance pay window. New shop-level setting
   offer_acceptance_payment_hours (default 24h, configurable in
   offer-settings form) bounds how long the buyer has to pay
   after acceptance. accept_offer() (both manual and auto-accept)
   stamps offer.accepted_timestamp; offer.acceptance_pay_deadline_ms
   is derived. offer_tick now expires ACCEPTED-but-unpaid offers past
   their deadline alongside the existing PENDING/COUNTERED expiry.

The offer detail page now shows the deadline to both buyer and seller,
and gives the buyer a "Cancel this offer" button alongside the pay-now
CTA.

Migration 632878c8f243 adds the two columns idempotently
(offer_acceptance_payment_hours on mps_shop, accepted_timestamp on
mps_offer). Existing accepted offers have accepted_timestamp = NULL;
the tick treats NULL as "no deadline" so legacy rows aren't
suddenly expired.

Tests:
- test_accepted_offer_expires_after_pay_window (integration)
- test_accepted_offer_inside_pay_window_not_touched (integration)
- test_buyer_cancel_after_accept_flips_state (integration, includes
  the paid-offer-cannot-be-cancelled guard)
- test_buyer_can_cancel_accepted_offer (functional)
- test_seller_cannot_cancel_accepted_offer (functional)
This commit is contained in:
russell@unturf.com 2026-05-13 10:31:43 -04:00
parent 87d054e7e8
commit 73c36e6f36
No known key found for this signature in database
12 changed files with 353 additions and 13 deletions

View file

@ -28,6 +28,7 @@ from ..models.offer import (
OFFER_STATE_EXPIRED,
OFFER_STATE_WITHDRAWN,
OFFER_STATE_PAID,
OFFER_STATE_BUYER_CANCELLED,
OFFER_TERMINAL_STATES,
OFFER_PARTY_BUYER,
OFFER_PARTY_SELLER,
@ -39,10 +40,12 @@ from ..models.offer import (
OFFER_EVENT_WITHDRAW,
OFFER_EVENT_EXPIRE,
OFFER_EVENT_PAY,
OFFER_EVENT_BUYER_CANCEL,
DEFAULT_OFFER_EXPIRATION_HOURS,
DEFAULT_OFFER_MAX_ROUNDS,
DEFAULT_OFFER_AUTO_ACCEPT_PCT,
DEFAULT_OFFER_AUTO_DECLINE_PCT,
DEFAULT_OFFER_ACCEPTANCE_PAYMENT_HOURS,
now_timestamp,
)
@ -180,6 +183,7 @@ def open_offer(
if decision == "accept":
offer.state = OFFER_STATE_ACCEPTED
offer.last_action_timestamp = now_ms
offer.accepted_timestamp = now_ms
dbsession.add(
MpsOfferEvent(
offer=offer,
@ -262,6 +266,7 @@ def accept_offer(offer, actor, actor_party, message=None, now_ms=None):
offer.state = OFFER_STATE_ACCEPTED
offer.last_action_timestamp = now_ms
offer.accepted_timestamp = now_ms
dbsession = offer.dbsession
dbsession.add(
@ -277,6 +282,35 @@ def accept_offer(offer, actor, actor_party, message=None, now_ms=None):
return offer
def cancel_offer_after_accept(offer, actor, message=None, now_ms=None):
"""Buyer-side back-out *after* the seller accepted. Flips ACCEPTED →
BUYER_CANCELLED. Distinct from withdraw (which only works before
acceptance). Terminal. Caller validates that actor is the buyer.
Raises OfferRejected if the offer isn't currently ACCEPTED — already
paid, expired, or never-accepted offers cannot be cancelled this way.
"""
if now_ms is None:
now_ms = now_timestamp()
if offer.state != OFFER_STATE_ACCEPTED:
raise OfferRejected("only accepted offers can be cancelled by buyer")
offer.state = OFFER_STATE_BUYER_CANCELLED
offer.last_action_timestamp = now_ms
dbsession = offer.dbsession
dbsession.add(
MpsOfferEvent(
offer=offer,
event_type=OFFER_EVENT_BUYER_CANCEL,
actor=actor,
amount_in_cents=offer.current_amount_in_cents,
message=message,
)
)
dbsession.flush()
return offer
def decline_offer(offer, actor, actor_party, message=None, now_ms=None):
"""Decline the current amount. Terminal. Either party can decline."""
if now_ms is None:
@ -327,15 +361,31 @@ def withdraw_offer(offer, actor, message=None, now_ms=None):
def expire_offer(offer, now_ms=None):
"""System action: flip non-terminal offers past their expiration to
EXPIRED. Idempotent already-terminal offers are returned unchanged.
Caller (tick job) is responsible for finding eligible offers."""
"""System action: flip a non-paid offer past its current deadline to
EXPIRED. Two deadlines apply at different states:
- PENDING / COUNTERED: offer.expires_timestamp (negotiation window).
- ACCEPTED: offer.acceptance_pay_deadline_ms (buyer's pay window).
Idempotent offers that are already terminal in a non-expirable way
(DECLINED, WITHDRAWN, PAID, BUYER_CANCELLED) are returned unchanged.
Caller (tick job) is responsible for finding eligible offers.
"""
if now_ms is None:
now_ms = now_timestamp()
if offer.state in OFFER_TERMINAL_STATES:
if offer.state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
if offer.expires_timestamp > now_ms:
return offer # negotiation window still open
reason = "auto-expired (negotiation window)"
elif offer.state == OFFER_STATE_ACCEPTED:
deadline = offer.acceptance_pay_deadline_ms
if deadline is None or deadline > now_ms:
return offer # buyer still inside the pay window
reason = "auto-expired (unpaid past acceptance window)"
else:
# DECLINED, WITHDRAWN, EXPIRED, PAID, BUYER_CANCELLED — no-op.
return offer
if offer.expires_timestamp > now_ms:
return offer # not yet expired
offer.state = OFFER_STATE_EXPIRED
offer.last_action_timestamp = now_ms
@ -347,7 +397,7 @@ def expire_offer(offer, now_ms=None):
event_type=OFFER_EVENT_EXPIRE,
actor=None,
amount_in_cents=offer.current_amount_in_cents,
message="auto-expired",
message=reason,
)
)
dbsession.flush()

View file

@ -1,4 +1,8 @@
"""offer_tick — auto-expiration for offers past expires_timestamp.
"""offer_tick — auto-expiration for offers past their current deadline.
Two windows expire:
- PENDING / COUNTERED past expires_timestamp (negotiation window).
- ACCEPTED past acceptance_pay_deadline_ms (buyer's pay window).
Idempotent: running twice on an already-expired offer is a no-op.
"""
@ -7,26 +11,46 @@ from ..models.offer import (
MpsOffer,
OFFER_STATE_PENDING,
OFFER_STATE_COUNTERED,
OFFER_STATE_ACCEPTED,
now_timestamp,
)
from .offer import expire_offer
def tick(dbsession, now_ms=None):
"""Expire any non-terminal offer past expires_timestamp.
Returns a dict {expired: int}. Caller manages its txn boundary.
"""Expire any pending/countered offer past expires_timestamp and any
accepted-but-unpaid offer past its acceptance pay deadline.
Returns {expired: int}. Caller manages its txn boundary.
"""
if now_ms is None:
now_ms = now_timestamp()
candidates = (
# Negotiation-window expiry (pre-acceptance).
pre_accept = (
dbsession.query(MpsOffer)
.filter(MpsOffer.state.in_([OFFER_STATE_PENDING, OFFER_STATE_COUNTERED]))
.filter(MpsOffer.expires_timestamp <= now_ms)
.all()
)
# Pay-window expiry (post-acceptance). expire_offer computes the
# deadline from accepted_timestamp + shop.offer_acceptance_payment_hours
# rather than a stored absolute, so we filter in-Python via the helper.
post_accept_candidates = (
dbsession.query(MpsOffer)
.filter(MpsOffer.state == OFFER_STATE_ACCEPTED)
.filter(MpsOffer.accepted_timestamp.isnot(None))
.all()
)
expired = 0
for offer in candidates:
for offer in pre_accept:
expire_offer(offer, now_ms=now_ms)
expired += 1
for offer in post_accept_candidates:
deadline = offer.acceptance_pay_deadline_ms
if deadline is None or deadline > now_ms:
continue
expire_offer(offer, now_ms=now_ms)
expired += 1
return {"expired": expired}

View file

@ -47,6 +47,11 @@ OFFER_STATE_DECLINED = 3
OFFER_STATE_EXPIRED = 4
OFFER_STATE_WITHDRAWN = 5
OFFER_STATE_PAID = 6
# Buyer-side back-out *after* the offer was accepted. Distinct from
# WITHDRAWN (which means the buyer pulled the offer mid-negotiation,
# before the seller responded). Surfaced separately in the seller
# inbox — they need to know the offer collapsed despite acceptance.
OFFER_STATE_BUYER_CANCELLED = 7
OFFER_STATE_INT_TO_HUMAN = {
OFFER_STATE_PENDING: "Pending",
@ -56,6 +61,7 @@ OFFER_STATE_INT_TO_HUMAN = {
OFFER_STATE_EXPIRED: "Expired",
OFFER_STATE_WITHDRAWN: "Withdrawn",
OFFER_STATE_PAID: "Paid",
OFFER_STATE_BUYER_CANCELLED: "Cancelled by buyer",
}
OFFER_TERMINAL_STATES = (
@ -64,6 +70,7 @@ OFFER_TERMINAL_STATES = (
OFFER_STATE_EXPIRED,
OFFER_STATE_WITHDRAWN,
OFFER_STATE_PAID,
OFFER_STATE_BUYER_CANCELLED,
)
OFFER_PARTY_BUYER = 0
@ -82,6 +89,7 @@ OFFER_EVENT_DECLINE = 3
OFFER_EVENT_WITHDRAW = 4
OFFER_EVENT_EXPIRE = 5
OFFER_EVENT_PAY = 6
OFFER_EVENT_BUYER_CANCEL = 7 # buyer-side back-out after acceptance
OFFER_EVENT_INT_TO_HUMAN = {
OFFER_EVENT_OPEN: "Opened",
@ -91,14 +99,16 @@ OFFER_EVENT_INT_TO_HUMAN = {
OFFER_EVENT_WITHDRAW: "Withdrew",
OFFER_EVENT_EXPIRE: "Expired",
OFFER_EVENT_PAY: "Paid",
OFFER_EVENT_BUYER_CANCEL: "Cancelled by buyer",
}
# Defaults — shop settings override these per-shop.
DEFAULT_OFFER_EXPIRATION_HOURS = 168 # 7 days
DEFAULT_OFFER_EXPIRATION_HOURS = 168 # 7 days, pre-acceptance window
DEFAULT_OFFER_MAX_ROUNDS = 3
DEFAULT_OFFER_AUTO_ACCEPT_PCT = 95
DEFAULT_OFFER_AUTO_DECLINE_PCT = 50
DEFAULT_OFFER_ACCEPTANCE_PAYMENT_HOURS = 24 # post-acceptance pay window
class MpsOffer(RBase, Base):
@ -137,6 +147,10 @@ class MpsOffer(RBase, Base):
last_action_timestamp = Column(BigInteger, nullable=False)
expires_timestamp = Column(BigInteger, nullable=False)
paid_timestamp = Column(BigInteger, nullable=True)
# When the seller (or auto-accept) flipped the offer to ACCEPTED.
# Drives the buyer's pay-by deadline: accepted_timestamp +
# shop.offer_acceptance_payment_hours.
accepted_timestamp = Column(BigInteger, nullable=True)
round_count = Column(
Integer, nullable=False, default=0, server_default="0"
@ -193,10 +207,38 @@ class MpsOffer(RBase, Base):
def is_paid(self):
return self.state == OFFER_STATE_PAID
@property
def is_buyer_cancelled(self):
return self.state == OFFER_STATE_BUYER_CANCELLED
@property
def is_terminal(self):
return self.state in OFFER_TERMINAL_STATES
@property
def acceptance_pay_deadline_ms(self):
"""ACCEPTED offers must be paid before this absolute timestamp,
else they auto-expire. Returns None for offers that never reached
acceptance.
"""
if self.accepted_timestamp is None:
return None
hours = (
self.shop.offer_acceptance_payment_hours
if self.shop is not None
else DEFAULT_OFFER_ACCEPTANCE_PAYMENT_HOURS
)
return self.accepted_timestamp + (hours * 3600 * 1000)
@property
def pay_time_remaining_ms(self):
"""How long the buyer has left to pay. Zero if past the deadline
or if the offer was never accepted."""
deadline = self.acceptance_pay_deadline_ms
if deadline is None:
return 0
return max(0, deadline - now_timestamp())
@property
def state_human(self):
return OFFER_STATE_INT_TO_HUMAN.get(self.state, "Unknown")

View file

@ -205,6 +205,9 @@ class Shop(RBase, Base):
offer_max_rounds = Column(
BigInteger, nullable=False, default=3, server_default="3"
)
offer_acceptance_payment_hours = Column(
BigInteger, nullable=False, default=24, server_default="24"
)
offer_min_buyer_account_age_hours = Column(
BigInteger, nullable=False, default=0, server_default="0"
)

View file

@ -259,6 +259,7 @@ def includeme(config):
config.add_route("offer_accept", "/o/{offer_id}/accept")
config.add_route("offer_decline", "/o/{offer_id}/decline")
config.add_route("offer_withdraw", "/o/{offer_id}/withdraw")
config.add_route("offer_cancel_after_accept", "/o/{offer_id}/cancel")
config.add_route("offer_checkout", "/o/{offer_id}/checkout")
config.add_route("offer_page", "/o/{offer_id}")

View file

@ -0,0 +1,55 @@
"""offer acceptance payment window + buyer cancelled state
Revision ID: 632878c8f243
Revises: 1a419114ddf7
Create Date: 2026-05-13 10:23:21.137263
Adds two columns:
- mps_shop.offer_acceptance_payment_hours: how long the buyer has to
pay after a seller accepts an offer. Default 24h.
- mps_offer.accepted_timestamp: when the seller accepted (or auto-
accept fired). Drives the buyer-pay deadline. Nullable for offers
that never reached acceptance.
No new state column is needed for BUYER_CANCELLED the existing
`state` integer is reused; lib/offer.py defines the new enum value.
"""
from alembic import op
import sqlalchemy as sa
revision = '632878c8f243'
down_revision = '1a419114ddf7'
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_shop", "offer_acceptance_payment_hours"):
op.add_column(
"mps_shop",
sa.Column(
"offer_acceptance_payment_hours",
sa.BigInteger(),
nullable=False,
server_default="24",
),
)
if not _column_exists("mps_offer", "accepted_timestamp"):
op.add_column(
"mps_offer",
sa.Column("accepted_timestamp", sa.BigInteger(), nullable=True),
)
def downgrade():
if _column_exists("mps_offer", "accepted_timestamp"):
op.drop_column("mps_offer", "accepted_timestamp")
if _column_exists("mps_shop", "offer_acceptance_payment_hours"):
op.drop_column("mps_shop", "offer_acceptance_payment_hours")

View file

@ -106,11 +106,20 @@
<form method="post" action="/o/{{ id }}/checkout">
<input type="submit" class="mps-submit mps-button-green" value="Pay ${{ "%.2f"|format(current_amount) }} now" />
</form>
{% if pay_deadline_human %}
<p class="offer-pay-deadline-note">You have until <strong>{{ pay_deadline_human }}</strong> to complete payment, after which this offer auto-expires.</p>
{% endif %}
<form method="post" action="/o/{{ id }}/cancel" class="offer-cancel-form" onsubmit="return confirm('Cancel this accepted offer? You will not be able to pay this amount again unless the seller accepts a new offer.');">
<button type="submit" class="cart-remove-link">Cancel this offer</button>
</form>
</section>
{% 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>. We emailed them a one-time checkout link &mdash; this offer can be redeemed only once.</p>
{% if pay_deadline_human %}
<p class="offer-await-deadline-note">Buyer must pay by <strong>{{ pay_deadline_human }}</strong>; the offer auto-expires after that.</p>
{% endif %}
<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();" />

View file

@ -1223,6 +1223,14 @@ Existing sales honored for download buy purchasers.
<small class="settings-field-hint">How long a queued offer waits before it auto-expires.</small>
</div>
<div class="settings-field">
<label for="offer_acceptance_payment_hours">Payment window after acceptance (hours)</label>
<input type="number" name="offer_acceptance_payment_hours" id="offer_acceptance_payment_hours"
value="{{ request.shop.offer_acceptance_payment_hours }}"
min="1" max="720" step="1" />
<small class="settings-field-hint">After you accept an offer, the buyer has this many hours to pay before the offer auto-expires. The buyer can also cancel during this window.</small>
</div>
<div class="settings-field">
<label for="offer_max_rounds">Max counter rounds</label>
<input type="number" name="offer_max_rounds" id="offer_max_rounds"

View file

@ -6878,6 +6878,44 @@ class TestOfferCheckout(_AuthenticatedBase):
# Seller never sees the buyer's pay-now form (they cannot pay).
self.assertNotIn(f"/o/{offer_id}/checkout", body)
def test_buyer_can_cancel_accepted_offer(self):
"""Buyer back-out path: POST /o/{id}/cancel flips ACCEPTED →
BUYER_CANCELLED. The seller's accept still stands as historical
record, but the offer can no longer be paid.
"""
from ..models.offer import (
OFFER_STATE_ACCEPTED, OFFER_STATE_BUYER_CANCELLED, get_offer_by_id,
)
offer_id = self._accepted_offer()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(f"/o/{offer_id}/cancel", status=302)
# Buyer is bounced back to the offer page, not the cart.
self.assertIn(f"/o/{offer_id}", res.location)
offer = get_offer_by_id(self.dbsession, offer_id)
self.dbsession.refresh(offer)
self.assertEqual(offer.state, OFFER_STATE_BUYER_CANCELLED)
# After cancel, /o/{id}/checkout no longer creates a cart.
check_res = self.testapp.post(f"/o/{offer_id}/checkout", status=302)
self.assertNotIn("/cart/", check_res.location)
def test_seller_cannot_cancel_accepted_offer(self):
"""Only the buyer can cancel an accepted offer. The seller's
only escape is decline-before-accept; after accepting they're
committed (the buyer chose to take the deal too)."""
offer_id = self._accepted_offer()
# _accepted_offer leaves user1 (seller) logged in.
res = self.testapp.post(f"/o/{offer_id}/cancel", status=302)
self.assertIn(f"/o/{offer_id}", res.location)
from ..models.offer import OFFER_STATE_ACCEPTED, get_offer_by_id
offer = get_offer_by_id(self.dbsession, offer_id)
self.dbsession.refresh(offer)
self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
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

View file

@ -5253,3 +5253,79 @@ class TestOfferTickIntegration(DatabaseIntegrationTests):
self.assertEqual(result1["expired"], 1)
result2 = tick(self.dbsession)
self.assertEqual(result2["expired"], 0)
def test_accepted_offer_expires_after_pay_window(self):
"""ACCEPTED offers past shop.offer_acceptance_payment_hours flip
to EXPIRED when the tick runs."""
from ..lib.offer_tick import tick
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, OFFER_STATE_EXPIRED, now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
shop.offer_acceptance_payment_hours = 1 # 1 hour pay window
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() + 86_400_000,
)
# Pretend it was accepted 2 hours ago — past the 1-hour pay window.
offer.state = OFFER_STATE_ACCEPTED
offer.accepted_timestamp = now_timestamp() - (2 * 3600 * 1000)
self.dbsession.add(offer)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["expired"], 1)
self.assertEqual(offer.state, OFFER_STATE_EXPIRED)
def test_accepted_offer_inside_pay_window_not_touched(self):
"""ACCEPTED offers still within the pay window stay accepted."""
from ..lib.offer_tick import tick
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
shop.offer_acceptance_payment_hours = 24
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
offer.accepted_timestamp = now_timestamp() - 1000 # 1s ago
self.dbsession.add(offer)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["expired"], 0)
self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
def test_buyer_cancel_after_accept_flips_state(self):
"""cancel_offer_after_accept flips ACCEPTED → BUYER_CANCELLED."""
from ..lib.offer import cancel_offer_after_accept, OfferRejected
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, OFFER_STATE_BUYER_CANCELLED,
OFFER_STATE_PAID, now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
offer.accepted_timestamp = now_timestamp()
self.dbsession.add(offer)
self.dbsession.flush()
cancel_offer_after_accept(offer, buyer, message="changed mind")
self.assertEqual(offer.state, OFFER_STATE_BUYER_CANCELLED)
self.assertTrue(offer.is_buyer_cancelled)
# Event log captured the cancellation.
latest = offer.events.order_by(None).all()[-1]
self.assertEqual(latest.message, "changed mind")
# Cannot cancel a paid offer.
offer.state = OFFER_STATE_PAID
with self.assertRaises(OfferRejected):
cancel_offer_after_accept(offer, buyer)

View file

@ -26,6 +26,7 @@ from ..lib.offer import (
accept_offer,
decline_offer,
withdraw_offer,
cancel_offer_after_accept,
)
from ..lib.mail import (
send_offer_received_email,
@ -237,6 +238,15 @@ def offer_page(request):
and offer.is_open
and actor_party == offer.current_party
)
deadline = offer.acceptance_pay_deadline_ms
if deadline is not None:
from datetime import datetime, timezone
ctx["pay_deadline_human"] = datetime.fromtimestamp(
deadline / 1000, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M UTC")
else:
ctx["pay_deadline_human"] = None
return ctx
@ -328,6 +338,11 @@ def _offer_action(request, action_fn, message_required=False):
return _err(403, "only the buyer can withdraw")
action_fn(offer, request.user, message=message)
done_msg = "Offer withdrawn."
elif action_fn is cancel_offer_after_accept:
if actor_party != OFFER_PARTY_BUYER:
return _err(403, "only the buyer can cancel an accepted offer")
action_fn(offer, request.user, message=message)
done_msg = "Offer cancelled."
elif action_fn is accept_offer:
action_fn(offer, request.user, actor_party, message=message)
done_msg = "Offer accepted."
@ -387,6 +402,20 @@ def offer_withdraw(request):
return _offer_action(request, withdraw_offer)
@view_config(
route_name="offer_cancel_after_accept",
request_method="POST",
renderer="json",
)
@user_required(flash_msg="Please log in to act on this offer.")
def offer_cancel_after_accept_view(request):
"""Buyer back-out *after* the offer was accepted. Different terminal
state than withdraw so the seller's inbox can distinguish "they
pulled it mid-negotiation" from "they ghosted after I accepted."
"""
return _offer_action(request, cancel_offer_after_accept)
@view_config(route_name="offer_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def offer_checkout(request):

View file

@ -1400,6 +1400,11 @@ def shop_settings(request):
"offer_expiration_hours", shop.offer_expiration_hours,
1, 24 * 365,
)
shop.offer_acceptance_payment_hours = _int_param(
"offer_acceptance_payment_hours",
shop.offer_acceptance_payment_hours,
1, 24 * 30,
)
shop.offer_max_rounds = _int_param(
"offer_max_rounds", shop.offer_max_rounds, 1, 100,
)