MPS-21: lib/offer.py — counter/accept/decline/expire state machine
Pure validators: - validate_actor_turn: actor's party must match offer.current_party; terminal-state offers reject all actions - validate_round_cap: rejects when round_count >= shop.offer_max_rounds (forces accept/decline at the cap) - validate_floor: silent reject below shop.offer_min_in_cents - auto_resolve_open: classifies a new offer as accept/decline/queue using shop's auto_accept_threshold_pct (default 95) and auto_decline_threshold_pct (default 50); list_price=0 always queues Orchestrators (write OFFER_EVENT_* rows for audit log): - open_offer: writes offer + OPEN event; applies auto-accept/decline thresholds before queuing seller; expires_timestamp = now + shop's expiration_hours - counter_offer: flips current_party, increments round_count, sets state COUNTERED, persists actor's message - accept_offer: terminal — caller's responsibility to write a cart line item at offer.current_amount_in_cents - decline_offer: terminal - withdraw_offer: terminal; buyer-only (caller validates identity) - expire_offer: idempotent system action — flips non-terminal offers past expires_timestamp to EXPIRED - mark_paid: cart-success hook; ACCEPTED → PAID; raises if not in ACCEPTED state OfferRejected exception carries reason in .args[0]. Self-offer (buyer == seller) blocking is the view layer's job — same pattern as auctions. Tests: - 12 unit tests: validate_actor_turn (terminal/wrong-party/correct-party), validate_round_cap (at-or-above/below), validate_floor (none/below/at), auto_resolve_open (accept/decline/queue/free-product) - 10 integration tests: auto-accept high offer, auto-decline low offer, queue mid-range offer, floor enforcement, full negotiation flow (open → seller counter → buyer counter → seller accept), round cap, decline terminal, withdraw, expire only past expiration, mark_paid only after accept Total: 881 tests pass (was 859 + 22).
This commit is contained in:
parent
a75cacdfcb
commit
4fcd1eacaa
3 changed files with 672 additions and 0 deletions
381
make_post_sell/lib/offer.py
Normal file
381
make_post_sell/lib/offer.py
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
"""Offer logic — MPS-21.
|
||||
|
||||
Pure validators + orchestrators for the make-an-offer state machine.
|
||||
Buyer opens an offer, seller counters or accepts. Either party can
|
||||
walk away with decline/withdraw. The round counter caps haggling
|
||||
loops; expiration_hours auto-ages out stale offers.
|
||||
|
||||
Auto-accept / auto-decline:
|
||||
- When buyer opens with amount >= list_price * (auto_accept_threshold_pct / 100),
|
||||
the offer skips seller queue and lands in ACCEPTED state.
|
||||
- When amount < list_price * (auto_decline_threshold_pct / 100),
|
||||
the offer goes straight to DECLINED. Seller never sees the lowball.
|
||||
|
||||
These thresholds are shop-level settings (offer_auto_accept_threshold_pct,
|
||||
offer_auto_decline_threshold_pct) with sane defaults (95 and 50).
|
||||
|
||||
Self-offer (buyer == seller) blocking is the view layer's job — same
|
||||
pattern as auctions.
|
||||
"""
|
||||
|
||||
from ..models.offer import (
|
||||
MpsOffer,
|
||||
MpsOfferEvent,
|
||||
OFFER_STATE_PENDING,
|
||||
OFFER_STATE_ACCEPTED,
|
||||
OFFER_STATE_COUNTERED,
|
||||
OFFER_STATE_DECLINED,
|
||||
OFFER_STATE_EXPIRED,
|
||||
OFFER_STATE_WITHDRAWN,
|
||||
OFFER_STATE_PAID,
|
||||
OFFER_TERMINAL_STATES,
|
||||
OFFER_PARTY_BUYER,
|
||||
OFFER_PARTY_SELLER,
|
||||
OFFER_PARTY_OTHER,
|
||||
OFFER_EVENT_OPEN,
|
||||
OFFER_EVENT_COUNTER,
|
||||
OFFER_EVENT_ACCEPT,
|
||||
OFFER_EVENT_DECLINE,
|
||||
OFFER_EVENT_WITHDRAW,
|
||||
OFFER_EVENT_EXPIRE,
|
||||
OFFER_EVENT_PAY,
|
||||
DEFAULT_OFFER_EXPIRATION_HOURS,
|
||||
DEFAULT_OFFER_MAX_ROUNDS,
|
||||
DEFAULT_OFFER_AUTO_ACCEPT_PCT,
|
||||
DEFAULT_OFFER_AUTO_DECLINE_PCT,
|
||||
now_timestamp,
|
||||
)
|
||||
|
||||
|
||||
class OfferRejected(Exception):
|
||||
"""Raised when an offer action is invalid. Reason in .args[0]."""
|
||||
|
||||
|
||||
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_actor_turn(actor_party, current_party, offer_state):
|
||||
"""Raise OfferRejected if it's not actor_party's turn to act on a
|
||||
pending/countered offer.
|
||||
|
||||
Pure: caller resolves whether the user is buyer or seller and passes
|
||||
actor_party as OFFER_PARTY_BUYER or OFFER_PARTY_SELLER.
|
||||
"""
|
||||
if offer_state in OFFER_TERMINAL_STATES:
|
||||
raise OfferRejected("offer is terminal")
|
||||
if actor_party != current_party:
|
||||
raise OfferRejected("not your turn to act on this offer")
|
||||
|
||||
|
||||
def validate_round_cap(round_count, max_rounds):
|
||||
"""Raise OfferRejected if round_count has reached max_rounds.
|
||||
|
||||
The opening offer is round 0; first counter takes it to 1. Once
|
||||
round_count >= max_rounds, no more counters are allowed and the
|
||||
current party must accept or decline.
|
||||
"""
|
||||
if round_count >= max_rounds:
|
||||
raise OfferRejected(
|
||||
f"counter limit ({max_rounds}) reached — accept or decline only"
|
||||
)
|
||||
|
||||
|
||||
def validate_floor(amount_in_cents, floor_in_cents):
|
||||
"""Raise OfferRejected if amount is below shop floor.
|
||||
|
||||
floor_in_cents may be None (no floor configured); pass through as valid.
|
||||
"""
|
||||
if floor_in_cents is None:
|
||||
return
|
||||
if amount_in_cents < floor_in_cents:
|
||||
raise OfferRejected("offer below shop minimum")
|
||||
|
||||
|
||||
def auto_resolve_open(amount_in_cents, list_price_in_cents,
|
||||
auto_accept_pct, auto_decline_pct):
|
||||
"""Pure: classify a new offer against shop thresholds.
|
||||
|
||||
Returns one of "accept", "decline", or "queue" (seller decides).
|
||||
The percentages are integers in [0, 100]; threshold is computed as
|
||||
list_price * pct / 100 with truncation.
|
||||
|
||||
auto_accept fires first: amount >= list * accept_pct / 100 → accept.
|
||||
auto_decline fires next: amount < list * decline_pct / 100 → decline.
|
||||
Otherwise → queue for seller review.
|
||||
"""
|
||||
if list_price_in_cents <= 0:
|
||||
return "queue" # free product or unset; never auto-resolve
|
||||
accept_floor = (list_price_in_cents * auto_accept_pct) // 100
|
||||
decline_floor = (list_price_in_cents * auto_decline_pct) // 100
|
||||
if amount_in_cents >= accept_floor:
|
||||
return "accept"
|
||||
if amount_in_cents < decline_floor:
|
||||
return "decline"
|
||||
return "queue"
|
||||
|
||||
|
||||
# ── Orchestrators ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def open_offer(
|
||||
dbsession,
|
||||
product,
|
||||
shop,
|
||||
buyer,
|
||||
amount_in_cents,
|
||||
buyer_message=None,
|
||||
now_ms=None,
|
||||
):
|
||||
"""Create an MpsOffer and write the OPEN event. Applies auto-accept /
|
||||
auto-decline thresholds before queuing for seller. Returns the new
|
||||
offer in whichever terminal/non-terminal state results.
|
||||
"""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
|
||||
floor = getattr(shop, "offer_min_in_cents", None)
|
||||
validate_floor(amount_in_cents, floor)
|
||||
|
||||
expiration_hours = (
|
||||
getattr(shop, "offer_expiration_hours", None)
|
||||
or DEFAULT_OFFER_EXPIRATION_HOURS
|
||||
)
|
||||
expires_timestamp = now_ms + (expiration_hours * 3600 * 1000)
|
||||
|
||||
offer = MpsOffer(
|
||||
product=product,
|
||||
shop=shop,
|
||||
buyer=buyer,
|
||||
amount_in_cents=amount_in_cents,
|
||||
expires_timestamp=expires_timestamp,
|
||||
buyer_message=buyer_message,
|
||||
)
|
||||
dbsession.add(offer)
|
||||
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_OPEN,
|
||||
actor=buyer,
|
||||
amount_in_cents=amount_in_cents,
|
||||
message=buyer_message,
|
||||
)
|
||||
)
|
||||
|
||||
# Auto-accept / auto-decline.
|
||||
auto_accept_pct = (
|
||||
getattr(shop, "offer_auto_accept_threshold_pct", None)
|
||||
or DEFAULT_OFFER_AUTO_ACCEPT_PCT
|
||||
)
|
||||
auto_decline_pct = (
|
||||
getattr(shop, "offer_auto_decline_threshold_pct", None)
|
||||
or DEFAULT_OFFER_AUTO_DECLINE_PCT
|
||||
)
|
||||
decision = auto_resolve_open(
|
||||
amount_in_cents=amount_in_cents,
|
||||
list_price_in_cents=product.price_in_cents,
|
||||
auto_accept_pct=auto_accept_pct,
|
||||
auto_decline_pct=auto_decline_pct,
|
||||
)
|
||||
if decision == "accept":
|
||||
offer.state = OFFER_STATE_ACCEPTED
|
||||
offer.last_action_timestamp = now_ms
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_ACCEPT,
|
||||
actor=None, # system
|
||||
amount_in_cents=amount_in_cents,
|
||||
message="auto-accept threshold met",
|
||||
)
|
||||
)
|
||||
elif decision == "decline":
|
||||
offer.state = OFFER_STATE_DECLINED
|
||||
offer.last_action_timestamp = now_ms
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_DECLINE,
|
||||
actor=None,
|
||||
amount_in_cents=amount_in_cents,
|
||||
message="auto-decline threshold not met",
|
||||
)
|
||||
)
|
||||
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
||||
|
||||
def counter_offer(
|
||||
offer,
|
||||
actor,
|
||||
actor_party,
|
||||
new_amount_in_cents,
|
||||
message=None,
|
||||
now_ms=None,
|
||||
):
|
||||
"""The current_party counters with a new amount. Flips current_party
|
||||
to the other side, increments round_count, sets state to COUNTERED.
|
||||
Raises OfferRejected if it's not the actor's turn or the round cap
|
||||
has been reached.
|
||||
"""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
|
||||
validate_actor_turn(actor_party, offer.current_party, offer.state)
|
||||
|
||||
shop = offer.shop
|
||||
max_rounds = getattr(shop, "offer_max_rounds", None) or DEFAULT_OFFER_MAX_ROUNDS
|
||||
validate_round_cap(offer.round_count, max_rounds)
|
||||
validate_floor(new_amount_in_cents, getattr(shop, "offer_min_in_cents", None))
|
||||
|
||||
offer.current_amount_in_cents = new_amount_in_cents
|
||||
offer.current_party = OFFER_PARTY_OTHER[offer.current_party]
|
||||
offer.last_action_timestamp = now_ms
|
||||
offer.round_count += 1
|
||||
offer.state = OFFER_STATE_COUNTERED
|
||||
if actor_party == OFFER_PARTY_SELLER:
|
||||
offer.seller_message = message
|
||||
else:
|
||||
offer.buyer_message = message
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_COUNTER,
|
||||
actor=actor,
|
||||
amount_in_cents=new_amount_in_cents,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
||||
|
||||
def accept_offer(offer, actor, actor_party, message=None, now_ms=None):
|
||||
"""Accept whatever amount is currently on the table. Either party
|
||||
can accept what's been countered to them. Terminal."""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
validate_actor_turn(actor_party, offer.current_party, offer.state)
|
||||
|
||||
offer.state = OFFER_STATE_ACCEPTED
|
||||
offer.last_action_timestamp = now_ms
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_ACCEPT,
|
||||
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:
|
||||
now_ms = now_timestamp()
|
||||
validate_actor_turn(actor_party, offer.current_party, offer.state)
|
||||
|
||||
offer.state = OFFER_STATE_DECLINED
|
||||
offer.last_action_timestamp = now_ms
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_DECLINE,
|
||||
actor=actor,
|
||||
amount_in_cents=offer.current_amount_in_cents,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
||||
|
||||
def withdraw_offer(offer, actor, message=None, now_ms=None):
|
||||
"""Buyer pulls the offer. Terminal. Buyer-only — caller validates
|
||||
identity. We accept withdraw at any non-terminal state.
|
||||
"""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
if offer.state in OFFER_TERMINAL_STATES:
|
||||
raise OfferRejected("offer is terminal")
|
||||
|
||||
offer.state = OFFER_STATE_WITHDRAWN
|
||||
offer.last_action_timestamp = now_ms
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_WITHDRAW,
|
||||
actor=actor,
|
||||
amount_in_cents=offer.current_amount_in_cents,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
||||
|
||||
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."""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
if offer.state in OFFER_TERMINAL_STATES:
|
||||
return offer
|
||||
if offer.expires_timestamp > now_ms:
|
||||
return offer # not yet expired
|
||||
|
||||
offer.state = OFFER_STATE_EXPIRED
|
||||
offer.last_action_timestamp = now_ms
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_EXPIRE,
|
||||
actor=None,
|
||||
amount_in_cents=offer.current_amount_in_cents,
|
||||
message="auto-expired",
|
||||
)
|
||||
)
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
||||
|
||||
def mark_paid(offer, now_ms=None):
|
||||
"""Cart-success hook. Flips ACCEPTED → PAID. No-op if already paid."""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
if offer.state == OFFER_STATE_PAID:
|
||||
return offer
|
||||
if offer.state != OFFER_STATE_ACCEPTED:
|
||||
raise OfferRejected("only accepted offers can be marked paid")
|
||||
|
||||
offer.state = OFFER_STATE_PAID
|
||||
offer.last_action_timestamp = now_ms
|
||||
offer.paid_timestamp = now_ms
|
||||
|
||||
dbsession = offer.dbsession
|
||||
dbsession.add(
|
||||
MpsOfferEvent(
|
||||
offer=offer,
|
||||
event_type=OFFER_EVENT_PAY,
|
||||
actor=None,
|
||||
amount_in_cents=offer.current_amount_in_cents,
|
||||
message="cart payment captured",
|
||||
)
|
||||
)
|
||||
dbsession.flush()
|
||||
return offer
|
||||
|
|
@ -4520,3 +4520,187 @@ class TestAuctionPlaceBid(DatabaseIntegrationTests):
|
|||
u = self._make_user("draft@example.com")
|
||||
with self.assertRaises(BidRejected):
|
||||
place_bid(auction, u, amount_in_cents=1000)
|
||||
|
||||
|
||||
class TestOfferLibOrchestrators(DatabaseIntegrationTests):
|
||||
"""MPS-21: lib/offer.py orchestrators with real DB persistence."""
|
||||
|
||||
def _make_shop_product_buyer(self, list_price=10000, offer_enabled=True,
|
||||
auto_accept_pct=95, auto_decline_pct=50,
|
||||
max_rounds=3, expiration_hours=168,
|
||||
offer_min=None):
|
||||
shop = Shop(
|
||||
name="Offer Test Shop", phone_number="x", billing_address="x",
|
||||
description="x",
|
||||
)
|
||||
shop.stripe_public_api_key = "pk_test"
|
||||
shop.stripe_secret_api_key = "sk_test"
|
||||
shop.domain_name = "offerlib.test"
|
||||
shop.offer_enabled = offer_enabled
|
||||
shop.offer_auto_accept_threshold_pct = auto_accept_pct
|
||||
shop.offer_auto_decline_threshold_pct = auto_decline_pct
|
||||
shop.offer_max_rounds = max_rounds
|
||||
shop.offer_expiration_hours = expiration_hours
|
||||
shop.offer_min_in_cents = offer_min
|
||||
self.dbsession.add(shop)
|
||||
|
||||
product = Product(title="Up for offers", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = list_price
|
||||
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, "buyer@offerlib.test")
|
||||
self.dbsession.add(buyer)
|
||||
self.dbsession.flush()
|
||||
return shop, product, buyer
|
||||
|
||||
def _make_seller(self, email="seller@offerlib.test"):
|
||||
u = get_or_create_user_by_email(self.dbsession, email)
|
||||
self.dbsession.add(u)
|
||||
self.dbsession.flush()
|
||||
return u
|
||||
|
||||
def test_open_offer_below_threshold_auto_declines(self):
|
||||
from ..lib.offer import open_offer
|
||||
from ..models.offer import OFFER_STATE_DECLINED
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
# 40% of 10000 = 4000 → below 50% decline threshold.
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 4000)
|
||||
self.assertEqual(offer.state, OFFER_STATE_DECLINED)
|
||||
# Two events: OPEN + DECLINE.
|
||||
self.assertEqual(offer.events.count(), 2)
|
||||
|
||||
def test_open_offer_above_threshold_auto_accepts(self):
|
||||
from ..lib.offer import open_offer
|
||||
from ..models.offer import OFFER_STATE_ACCEPTED
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
# 96% of 10000 = 9600 → above 95% accept threshold.
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 9600)
|
||||
self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
|
||||
self.assertEqual(offer.events.count(), 2) # OPEN + ACCEPT
|
||||
|
||||
def test_open_offer_in_range_queues_for_seller(self):
|
||||
from ..lib.offer import open_offer
|
||||
from ..models.offer import (
|
||||
OFFER_STATE_PENDING, OFFER_PARTY_SELLER,
|
||||
)
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
# 70% of 10000 = 7000 → between 50 and 95.
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
self.assertEqual(offer.state, OFFER_STATE_PENDING)
|
||||
self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)
|
||||
self.assertEqual(offer.events.count(), 1) # OPEN only
|
||||
self.assertEqual(offer.round_count, 0)
|
||||
|
||||
def test_open_offer_below_floor_raises(self):
|
||||
from ..lib.offer import open_offer, OfferRejected
|
||||
shop, product, buyer = self._make_shop_product_buyer(
|
||||
list_price=10000, offer_min=5000,
|
||||
)
|
||||
with self.assertRaises(OfferRejected):
|
||||
open_offer(self.dbsession, product, shop, buyer, 4999)
|
||||
|
||||
def test_full_negotiation_flow(self):
|
||||
from ..lib.offer import open_offer, counter_offer, accept_offer
|
||||
from ..models.offer import (
|
||||
OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, OFFER_STATE_ACCEPTED,
|
||||
)
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
seller = self._make_seller()
|
||||
|
||||
# Buyer opens at 7000 (in queue range).
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)
|
||||
|
||||
# Seller counters at 8500.
|
||||
counter_offer(
|
||||
offer, seller, OFFER_PARTY_SELLER, 8500, message="how about this?",
|
||||
)
|
||||
self.assertEqual(offer.current_amount_in_cents, 8500)
|
||||
self.assertEqual(offer.current_party, OFFER_PARTY_BUYER)
|
||||
self.assertEqual(offer.round_count, 1)
|
||||
|
||||
# Buyer counters at 8000.
|
||||
counter_offer(
|
||||
offer, buyer, OFFER_PARTY_BUYER, 8000, message="I'll meet halfway",
|
||||
)
|
||||
self.assertEqual(offer.current_amount_in_cents, 8000)
|
||||
self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)
|
||||
self.assertEqual(offer.round_count, 2)
|
||||
|
||||
# Seller accepts.
|
||||
accept_offer(offer, seller, OFFER_PARTY_SELLER, "deal")
|
||||
self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
|
||||
|
||||
# Events: open + counter + counter + accept = 4
|
||||
self.assertEqual(offer.events.count(), 4)
|
||||
|
||||
def test_round_cap_blocks_extra_counter(self):
|
||||
from ..lib.offer import open_offer, counter_offer, OfferRejected
|
||||
from ..models.offer import OFFER_PARTY_BUYER, OFFER_PARTY_SELLER
|
||||
shop, product, buyer = self._make_shop_product_buyer(
|
||||
list_price=10000, max_rounds=2,
|
||||
)
|
||||
seller = self._make_seller()
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
|
||||
counter_offer(offer, seller, OFFER_PARTY_SELLER, 8000)
|
||||
counter_offer(offer, buyer, OFFER_PARTY_BUYER, 7500)
|
||||
# Round cap = 2; another counter should raise.
|
||||
with self.assertRaises(OfferRejected):
|
||||
counter_offer(offer, seller, OFFER_PARTY_SELLER, 7800)
|
||||
|
||||
def test_decline_terminates(self):
|
||||
from ..lib.offer import open_offer, decline_offer
|
||||
from ..models.offer import OFFER_STATE_DECLINED, OFFER_PARTY_SELLER
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
seller = self._make_seller()
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
decline_offer(offer, seller, OFFER_PARTY_SELLER, "no thanks")
|
||||
self.assertEqual(offer.state, OFFER_STATE_DECLINED)
|
||||
|
||||
def test_withdraw_buyer_only(self):
|
||||
from ..lib.offer import open_offer, withdraw_offer
|
||||
from ..models.offer import OFFER_STATE_WITHDRAWN
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
withdraw_offer(offer, buyer, "changed my mind")
|
||||
self.assertEqual(offer.state, OFFER_STATE_WITHDRAWN)
|
||||
|
||||
def test_expire_only_past_expiration(self):
|
||||
from ..lib.offer import open_offer, expire_offer
|
||||
from ..models.offer import (
|
||||
OFFER_STATE_PENDING, OFFER_STATE_EXPIRED, now_timestamp,
|
||||
)
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
|
||||
# Not yet expired → no-op.
|
||||
expire_offer(offer, now_ms=now_timestamp())
|
||||
self.assertEqual(offer.state, OFFER_STATE_PENDING)
|
||||
|
||||
# Force time past expiration.
|
||||
expire_offer(offer, now_ms=offer.expires_timestamp + 1)
|
||||
self.assertEqual(offer.state, OFFER_STATE_EXPIRED)
|
||||
|
||||
def test_mark_paid_only_after_accept(self):
|
||||
from ..lib.offer import (
|
||||
open_offer, accept_offer, mark_paid, OfferRejected,
|
||||
)
|
||||
from ..models.offer import OFFER_STATE_PAID, OFFER_PARTY_SELLER
|
||||
shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
|
||||
seller = self._make_seller()
|
||||
|
||||
offer = open_offer(self.dbsession, product, shop, buyer, 7000)
|
||||
# Cannot mark pending offer paid.
|
||||
with self.assertRaises(OfferRejected):
|
||||
mark_paid(offer)
|
||||
|
||||
accept_offer(offer, seller, OFFER_PARTY_SELLER)
|
||||
mark_paid(offer)
|
||||
self.assertEqual(offer.state, OFFER_STATE_PAID)
|
||||
self.assertIsNotNone(offer.paid_timestamp)
|
||||
|
|
|
|||
|
|
@ -4647,3 +4647,110 @@ class TestAuctionLibProxyResolution(unittest.TestCase):
|
|||
)
|
||||
self.assertFalse(new_wins)
|
||||
self.assertEqual(winning, 4100)
|
||||
|
||||
|
||||
class TestOfferLibPureFunctions(unittest.TestCase):
|
||||
"""MPS-21: lib/offer.py pure validators — no DB."""
|
||||
|
||||
def test_validate_actor_turn_rejects_terminal(self):
|
||||
from ..lib.offer import validate_actor_turn, OfferRejected
|
||||
from ..models.offer import (
|
||||
OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
|
||||
OFFER_TERMINAL_STATES,
|
||||
)
|
||||
for terminal in OFFER_TERMINAL_STATES:
|
||||
with self.assertRaises(OfferRejected):
|
||||
validate_actor_turn(
|
||||
actor_party=OFFER_PARTY_BUYER,
|
||||
current_party=OFFER_PARTY_BUYER,
|
||||
offer_state=terminal,
|
||||
)
|
||||
|
||||
def test_validate_actor_turn_rejects_wrong_party(self):
|
||||
from ..lib.offer import validate_actor_turn, OfferRejected
|
||||
from ..models.offer import (
|
||||
OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, OFFER_STATE_PENDING,
|
||||
)
|
||||
with self.assertRaises(OfferRejected):
|
||||
validate_actor_turn(
|
||||
actor_party=OFFER_PARTY_BUYER,
|
||||
current_party=OFFER_PARTY_SELLER,
|
||||
offer_state=OFFER_STATE_PENDING,
|
||||
)
|
||||
|
||||
def test_validate_actor_turn_accepts_correct_party(self):
|
||||
from ..lib.offer import validate_actor_turn
|
||||
from ..models.offer import (
|
||||
OFFER_PARTY_SELLER, OFFER_STATE_PENDING, OFFER_STATE_COUNTERED,
|
||||
)
|
||||
for state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
|
||||
validate_actor_turn(
|
||||
actor_party=OFFER_PARTY_SELLER,
|
||||
current_party=OFFER_PARTY_SELLER,
|
||||
offer_state=state,
|
||||
) # no raise
|
||||
|
||||
def test_validate_round_cap_rejects_at_or_above(self):
|
||||
from ..lib.offer import validate_round_cap, OfferRejected
|
||||
with self.assertRaises(OfferRejected):
|
||||
validate_round_cap(round_count=3, max_rounds=3)
|
||||
with self.assertRaises(OfferRejected):
|
||||
validate_round_cap(round_count=4, max_rounds=3)
|
||||
|
||||
def test_validate_round_cap_accepts_below(self):
|
||||
from ..lib.offer import validate_round_cap
|
||||
validate_round_cap(round_count=0, max_rounds=3)
|
||||
validate_round_cap(round_count=2, max_rounds=3)
|
||||
|
||||
def test_validate_floor_passes_when_no_floor(self):
|
||||
from ..lib.offer import validate_floor
|
||||
validate_floor(amount_in_cents=100, floor_in_cents=None)
|
||||
|
||||
def test_validate_floor_rejects_below(self):
|
||||
from ..lib.offer import validate_floor, OfferRejected
|
||||
with self.assertRaises(OfferRejected):
|
||||
validate_floor(amount_in_cents=99, floor_in_cents=100)
|
||||
|
||||
def test_validate_floor_accepts_at(self):
|
||||
from ..lib.offer import validate_floor
|
||||
validate_floor(amount_in_cents=100, floor_in_cents=100)
|
||||
|
||||
def test_auto_resolve_accept(self):
|
||||
from ..lib.offer import auto_resolve_open
|
||||
# 95% of 1000 = 950. Offer at 950+ → accept.
|
||||
self.assertEqual(
|
||||
auto_resolve_open(950, 1000, 95, 50), "accept",
|
||||
)
|
||||
self.assertEqual(
|
||||
auto_resolve_open(1000, 1000, 95, 50), "accept",
|
||||
)
|
||||
|
||||
def test_auto_resolve_decline(self):
|
||||
from ..lib.offer import auto_resolve_open
|
||||
# 50% of 1000 = 500. Offer below 500 → decline.
|
||||
self.assertEqual(
|
||||
auto_resolve_open(499, 1000, 95, 50), "decline",
|
||||
)
|
||||
self.assertEqual(
|
||||
auto_resolve_open(100, 1000, 95, 50), "decline",
|
||||
)
|
||||
|
||||
def test_auto_resolve_queue(self):
|
||||
from ..lib.offer import auto_resolve_open
|
||||
# Between 500 (50%) and 950 (95%) → queue.
|
||||
self.assertEqual(
|
||||
auto_resolve_open(700, 1000, 95, 50), "queue",
|
||||
)
|
||||
self.assertEqual(
|
||||
auto_resolve_open(500, 1000, 95, 50), "queue",
|
||||
)
|
||||
self.assertEqual(
|
||||
auto_resolve_open(949, 1000, 95, 50), "queue",
|
||||
)
|
||||
|
||||
def test_auto_resolve_free_product_queues(self):
|
||||
# list_price=0 → never auto-resolve (defensive).
|
||||
from ..lib.offer import auto_resolve_open
|
||||
self.assertEqual(
|
||||
auto_resolve_open(100, 0, 95, 50), "queue",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue