From 4fcd1eacaaecb8ab3d5ca31b2370de7b440dabee Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 9 May 2026 19:06:36 -0400 Subject: [PATCH] =?UTF-8?q?MPS-21:=20lib/offer.py=20=E2=80=94=20counter/ac?= =?UTF-8?q?cept/decline/expire=20state=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- make_post_sell/lib/offer.py | 381 +++++++++++++++++++++++ make_post_sell/tests/test_integration.py | 184 +++++++++++ make_post_sell/tests/test_models.py | 107 +++++++ 3 files changed, 672 insertions(+) create mode 100644 make_post_sell/lib/offer.py diff --git a/make_post_sell/lib/offer.py b/make_post_sell/lib/offer.py new file mode 100644 index 0000000..323c7a1 --- /dev/null +++ b/make_post_sell/lib/offer.py @@ -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 diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index a633128..b1566e1 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -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) diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 72d9ad3..632678f 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -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", + )