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:
russell@unturf.com 2026-05-09 19:06:36 -04:00
parent a75cacdfcb
commit 4fcd1eacaa
No known key found for this signature in database
3 changed files with 672 additions and 0 deletions

381
make_post_sell/lib/offer.py Normal file
View 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

View file

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

View file

@ -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",
)