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:
parent
87d054e7e8
commit
73c36e6f36
12 changed files with 353 additions and 13 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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>’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>
|
||||
{% 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();" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue