diff --git a/make_post_sell/lib/offer.py b/make_post_sell/lib/offer.py index 323c7a1..0035c84 100644 --- a/make_post_sell/lib/offer.py +++ b/make_post_sell/lib/offer.py @@ -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() diff --git a/make_post_sell/lib/offer_tick.py b/make_post_sell/lib/offer_tick.py index e7cef1b..a50d8a1 100644 --- a/make_post_sell/lib/offer_tick.py +++ b/make_post_sell/lib/offer_tick.py @@ -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} diff --git a/make_post_sell/models/offer.py b/make_post_sell/models/offer.py index b141b9a..d665595 100644 --- a/make_post_sell/models/offer.py +++ b/make_post_sell/models/offer.py @@ -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") diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 1b49bb7..b38df34 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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" ) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 0df291b..3fb4f10 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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}") diff --git a/make_post_sell/scripts/alembic/versions/632878c8f243_offer_acceptance_payment_window_buyer_.py b/make_post_sell/scripts/alembic/versions/632878c8f243_offer_acceptance_payment_window_buyer_.py new file mode 100644 index 0000000..05cb7be --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/632878c8f243_offer_acceptance_payment_window_buyer_.py @@ -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") diff --git a/make_post_sell/templates/offer.j2 b/make_post_sell/templates/offer.j2 index cdd9cc1..c4c97cf 100644 --- a/make_post_sell/templates/offer.j2 +++ b/make_post_sell/templates/offer.j2 @@ -106,11 +106,20 @@
+ {% if pay_deadline_human %} +

You have until {{ pay_deadline_human }} to complete payment, after which this offer auto-expires.

+ {% endif %} +
+ +
{% elif request.user %}

Awaiting payment

You accepted {{ buyer_name }}’s offer at ${{ "%.2f"|format(current_amount) }}. We emailed them a one-time checkout link — this offer can be redeemed only once.

+ {% if pay_deadline_human %} +

Buyer must pay by {{ pay_deadline_human }}; the offer auto-expires after that.

+ {% endif %}

If they need it again, share this same page:

+
+ + + 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. +
+