diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 3fff97b..478ed65 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -38,6 +38,9 @@ from .cart_gift_card import * from .api_key import * +from .auction import * +from .offer import * + # run configure_mappers after defining all of the models # to ensure all relationships can be setup. configure_mappers() diff --git a/make_post_sell/models/auction.py b/make_post_sell/models/auction.py new file mode 100644 index 0000000..e934f0e --- /dev/null +++ b/make_post_sell/models/auction.py @@ -0,0 +1,345 @@ +"""Auction models — MPS-20. + +An auction is a Product whose price is determined by competitive bidding +within a time window. The Product carries `pricing_mode` (0=fixed, 1=auction, +2=auction+buy_now); MpsAuction carries the auction-specific fields and +state machine; MpsBid is one bid per (auction, bidder, attempt); and +MpsAuctionWatcher lets users follow auctions for notifications. + +State machine (auction.state): + 0 draft — owner editing, not visible + 1 scheduled — countdown to start_timestamp + 2 active — bidding open + 3 ended — bidding closed; winner determined or reserve not met + 4 settled — winner paid via cart; product transferred + 5 cancelled — owner aborted (pre-active only without admin override) + +Soft-close: a bid placed within `soft_close_seconds` of `end_timestamp` +extends `end_timestamp` by `soft_close_seconds`. `original_end_timestamp` +preserves the scheduled close for audit. + +See `lib/auction.py` for pure-function bid/proxy/soft-close logic. +""" + +import uuid + +from sqlalchemy import Column, BigInteger, Boolean, Integer, Unicode +from sqlalchemy.orm import relationship, backref + +from .meta import ( + Base, + RBase, + UUIDType, + foreign_key, + now_timestamp, + get_object_by_id, +) + +from ..lib.currency import cents_to_dollars + + +# Auction state constants — mirror VISIBILITY_INT_TO_HUMAN style. +AUCTION_STATE_DRAFT = 0 +AUCTION_STATE_SCHEDULED = 1 +AUCTION_STATE_ACTIVE = 2 +AUCTION_STATE_ENDED = 3 +AUCTION_STATE_SETTLED = 4 +AUCTION_STATE_CANCELLED = 5 + +AUCTION_STATE_INT_TO_HUMAN = { + AUCTION_STATE_DRAFT: "Draft", + AUCTION_STATE_SCHEDULED: "Scheduled", + AUCTION_STATE_ACTIVE: "Active", + AUCTION_STATE_ENDED: "Ended", + AUCTION_STATE_SETTLED: "Settled", + AUCTION_STATE_CANCELLED: "Cancelled", +} + + +# Default soft-close window in seconds (mirrors eBay's last-minute extension). +DEFAULT_SOFT_CLOSE_SECONDS = 60 + +# Default bid increment as a percentage of current high (5%). The model +# stores this as cents — view layer computes default from listed price. +DEFAULT_BID_INCREMENT_IN_CENTS = 100 + + +class MpsAuction(RBase, Base): + """One auction per Product (1:1). Created when shop owner enables + pricing_mode=1 or 2 on a product.""" + + id = Column(UUIDType, primary_key=True, index=True) + product_id = Column( + UUIDType, + foreign_key("Product", "id"), + nullable=False, + unique=True, + index=True, + ) + shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True) + + state = Column( + Integer, + nullable=False, + default=AUCTION_STATE_DRAFT, + server_default=str(AUCTION_STATE_DRAFT), + ) + + start_timestamp = Column(BigInteger, nullable=True) + end_timestamp = Column(BigInteger, nullable=True) + original_end_timestamp = Column(BigInteger, nullable=True) + + start_price_in_cents = Column(BigInteger, nullable=False, default=0) + reserve_price_in_cents = Column(BigInteger, nullable=True) + buy_now_price_in_cents = Column(BigInteger, nullable=True) + bid_increment_in_cents = Column( + BigInteger, nullable=False, default=DEFAULT_BID_INCREMENT_IN_CENTS + ) + soft_close_seconds = Column( + Integer, nullable=False, default=DEFAULT_SOFT_CLOSE_SECONDS + ) + + winner_user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True) + winning_bid_id = Column(UUIDType, nullable=True) + payment_deadline_timestamp = Column(BigInteger, nullable=True) + + currency = Column(Unicode(3), nullable=False, default="USD") + + created_timestamp = Column(BigInteger, nullable=False) + updated_timestamp = Column(BigInteger, nullable=False) + + product = relationship( + argument="Product", + uselist=False, + backref=backref("auction", uselist=False), + ) + shop = relationship(argument="Shop", uselist=False) + winner = relationship(argument="User", uselist=False, foreign_keys=[winner_user_id]) + + bids = relationship( + argument="MpsBid", + lazy="dynamic", + order_by="MpsBid.created_timestamp.desc()", + back_populates="auction", + cascade="all, delete-orphan", + ) + watchers = relationship( + argument="MpsAuctionWatcher", + lazy="dynamic", + back_populates="auction", + cascade="all, delete-orphan", + ) + + def __init__(self, product, shop, start_price_in_cents=0, + bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS, + soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS, + currency="USD"): + self.id = uuid.uuid1() + self.product = product + self.shop = shop + self.state = AUCTION_STATE_DRAFT + self.start_price_in_cents = start_price_in_cents + self.bid_increment_in_cents = bid_increment_in_cents + self.soft_close_seconds = soft_close_seconds + self.currency = currency + self.created_timestamp = now_timestamp() + self.updated_timestamp = self.created_timestamp + + @property + def is_draft(self): + return self.state == AUCTION_STATE_DRAFT + + @property + def is_scheduled(self): + return self.state == AUCTION_STATE_SCHEDULED + + @property + def is_active(self): + return self.state == AUCTION_STATE_ACTIVE + + @property + def is_ended(self): + return self.state == AUCTION_STATE_ENDED + + @property + def is_settled(self): + return self.state == AUCTION_STATE_SETTLED + + @property + def is_cancelled(self): + return self.state == AUCTION_STATE_CANCELLED + + @property + def is_terminal(self): + return self.state in ( + AUCTION_STATE_SETTLED, + AUCTION_STATE_CANCELLED, + ) + + @property + def state_human(self): + return AUCTION_STATE_INT_TO_HUMAN.get(self.state, "Unknown") + + @property + def time_remaining_ms(self): + """Milliseconds until end_timestamp; 0 if past or unset.""" + if not self.end_timestamp: + return 0 + return max(0, self.end_timestamp - now_timestamp()) + + @property + def has_buy_now(self): + return self.buy_now_price_in_cents is not None + + @property + def has_reserve(self): + return self.reserve_price_in_cents is not None + + @property + def current_high_in_cents(self): + """Highest bid amount; falls back to start_price when no bids.""" + top = ( + self.bids.order_by(None) + .order_by(MpsBid.amount_in_cents.desc()) + .first() + ) + if top is None: + return self.start_price_in_cents + return top.amount_in_cents + + @property + def reserve_met(self): + if not self.has_reserve: + return True + return self.current_high_in_cents >= self.reserve_price_in_cents + + @property + def start_price(self): + return cents_to_dollars(self.start_price_in_cents) + + @property + def reserve_price(self): + if self.reserve_price_in_cents is None: + return None + return cents_to_dollars(self.reserve_price_in_cents) + + @property + def buy_now_price(self): + if self.buy_now_price_in_cents is None: + return None + return cents_to_dollars(self.buy_now_price_in_cents) + + @property + def bid_increment(self): + return cents_to_dollars(self.bid_increment_in_cents) + + @property + def current_high(self): + return cents_to_dollars(self.current_high_in_cents) + + @property + def min_next_bid_in_cents(self): + """Smallest bid that would be accepted right now.""" + if self.bids.count() == 0: + return self.start_price_in_cents + return self.current_high_in_cents + self.bid_increment_in_cents + + @property + def min_next_bid(self): + return cents_to_dollars(self.min_next_bid_in_cents) + + +class MpsBid(RBase, Base): + """One bid on an auction. amount_in_cents is the actual bid; max_proxy + is the bidder's secret ceiling, used by lib/auction proxy resolution.""" + + id = Column(UUIDType, primary_key=True, index=True) + auction_id = Column( + UUIDType, + foreign_key("MpsAuction", "id"), + nullable=False, + index=True, + ) + bidder_user_id = Column( + UUIDType, + foreign_key("User", "id"), + nullable=False, + index=True, + ) + amount_in_cents = Column(BigInteger, nullable=False) + max_proxy_in_cents = Column(BigInteger, nullable=True) + + created_timestamp = Column(BigInteger, nullable=False) + outbid_timestamp = Column(BigInteger, nullable=True) + is_winning = Column( + Boolean, nullable=False, default=False, server_default="0" + ) + + auction = relationship(argument="MpsAuction", uselist=False, back_populates="bids") + bidder = relationship(argument="User", uselist=False) + + def __init__(self, auction, bidder, amount_in_cents, max_proxy_in_cents=None): + self.id = uuid.uuid1() + self.auction = auction + self.bidder = bidder + self.amount_in_cents = amount_in_cents + self.max_proxy_in_cents = max_proxy_in_cents + self.created_timestamp = now_timestamp() + self.is_winning = False + + @property + def amount(self): + return cents_to_dollars(self.amount_in_cents) + + @property + def max_proxy(self): + if self.max_proxy_in_cents is None: + return None + return cents_to_dollars(self.max_proxy_in_cents) + + +class MpsAuctionWatcher(RBase, Base): + """User watches an auction. Drives ending-soon and outbid emails.""" + + id = Column(UUIDType, primary_key=True, index=True) + auction_id = Column( + UUIDType, + foreign_key("MpsAuction", "id"), + nullable=False, + index=True, + ) + user_id = Column( + UUIDType, + foreign_key("User", "id"), + nullable=False, + index=True, + ) + created_timestamp = Column(BigInteger, nullable=False) + notify_on_outbid = Column( + Boolean, nullable=False, default=True, server_default="1" + ) + notify_on_ending_soon = Column( + Boolean, nullable=False, default=True, server_default="1" + ) + + auction = relationship( + argument="MpsAuction", uselist=False, back_populates="watchers" + ) + user = relationship(argument="User", uselist=False) + + def __init__(self, auction, user, notify_on_outbid=True, + notify_on_ending_soon=True): + self.id = uuid.uuid1() + self.auction = auction + self.user = user + self.notify_on_outbid = notify_on_outbid + self.notify_on_ending_soon = notify_on_ending_soon + self.created_timestamp = now_timestamp() + + +def get_auction_by_id(dbsession, auction_id): + return get_object_by_id(dbsession, auction_id, MpsAuction) + + +def get_bid_by_id(dbsession, bid_id): + return get_object_by_id(dbsession, bid_id, MpsBid) diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index ba5fc6f..4ea08c4 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -47,6 +47,11 @@ CLASS_TO_TABLE = { "GiftCardTransaction": "mps_gift_card_transaction", "CartGiftCard": "mps_cart_gift_card", "MpsApiKey": "mps_api_key", + "MpsAuction": "mps_auction", + "MpsBid": "mps_bid", + "MpsAuctionWatcher": "mps_auction_watcher", + "MpsOffer": "mps_offer", + "MpsOfferEvent": "mps_offer_event", } diff --git a/make_post_sell/models/offer.py b/make_post_sell/models/offer.py new file mode 100644 index 0000000..b141b9a --- /dev/null +++ b/make_post_sell/models/offer.py @@ -0,0 +1,269 @@ +"""Offer models — MPS-21. + +Make-an-offer mode lets a buyer propose a price for a Product. The seller +can accept, counter, or decline. State machine permits a finite number +of counter rounds before forcing terminal resolution. Auto-accept (when +buyer offer >= threshold) and auto-decline (when below threshold) skip +the seller's queue entirely. + +State machine (offer.state): + 0 pending — offer waiting on the other party (current_party) + 1 accepted — terminal; cart line item created at agreed price + 2 countered — counter active (still pending to other party) + 3 declined — terminal; rejected + 4 expired — terminal; auto-expired past expires_timestamp + 5 withdrawn — terminal; buyer pulled the offer + 6 paid — terminal; cart payment succeeded, transferred + +current_party: + 0 buyer's turn (initial state, or seller just countered to buyer) + 1 seller's turn (buyer just countered or opened the offer) + +See `lib/offer.py` for pure-function counter/accept/decline/expire logic. +""" + +import uuid + +from sqlalchemy import Column, BigInteger, Integer, UnicodeText +from sqlalchemy.orm import relationship, backref + +from .meta import ( + Base, + RBase, + UUIDType, + foreign_key, + now_timestamp, + get_object_by_id, +) + +from ..lib.currency import cents_to_dollars + + +# State constants. +OFFER_STATE_PENDING = 0 +OFFER_STATE_ACCEPTED = 1 +OFFER_STATE_COUNTERED = 2 +OFFER_STATE_DECLINED = 3 +OFFER_STATE_EXPIRED = 4 +OFFER_STATE_WITHDRAWN = 5 +OFFER_STATE_PAID = 6 + +OFFER_STATE_INT_TO_HUMAN = { + OFFER_STATE_PENDING: "Pending", + OFFER_STATE_ACCEPTED: "Accepted", + OFFER_STATE_COUNTERED: "Countered", + OFFER_STATE_DECLINED: "Declined", + OFFER_STATE_EXPIRED: "Expired", + OFFER_STATE_WITHDRAWN: "Withdrawn", + OFFER_STATE_PAID: "Paid", +} + +OFFER_TERMINAL_STATES = ( + OFFER_STATE_ACCEPTED, + OFFER_STATE_DECLINED, + OFFER_STATE_EXPIRED, + OFFER_STATE_WITHDRAWN, + OFFER_STATE_PAID, +) + +OFFER_PARTY_BUYER = 0 +OFFER_PARTY_SELLER = 1 +OFFER_PARTY_OTHER = { + OFFER_PARTY_BUYER: OFFER_PARTY_SELLER, + OFFER_PARTY_SELLER: OFFER_PARTY_BUYER, +} + + +# Event log types. +OFFER_EVENT_OPEN = 0 +OFFER_EVENT_COUNTER = 1 +OFFER_EVENT_ACCEPT = 2 +OFFER_EVENT_DECLINE = 3 +OFFER_EVENT_WITHDRAW = 4 +OFFER_EVENT_EXPIRE = 5 +OFFER_EVENT_PAY = 6 + +OFFER_EVENT_INT_TO_HUMAN = { + OFFER_EVENT_OPEN: "Opened", + OFFER_EVENT_COUNTER: "Countered", + OFFER_EVENT_ACCEPT: "Accepted", + OFFER_EVENT_DECLINE: "Declined", + OFFER_EVENT_WITHDRAW: "Withdrew", + OFFER_EVENT_EXPIRE: "Expired", + OFFER_EVENT_PAY: "Paid", +} + + +# Defaults — shop settings override these per-shop. +DEFAULT_OFFER_EXPIRATION_HOURS = 168 # 7 days +DEFAULT_OFFER_MAX_ROUNDS = 3 +DEFAULT_OFFER_AUTO_ACCEPT_PCT = 95 +DEFAULT_OFFER_AUTO_DECLINE_PCT = 50 + + +class MpsOffer(RBase, Base): + """One open negotiation between a buyer and seller for a single Product.""" + + id = Column(UUIDType, primary_key=True, index=True) + product_id = Column( + UUIDType, + foreign_key("Product", "id"), + nullable=False, + index=True, + ) + shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True) + buyer_user_id = Column( + UUIDType, + foreign_key("User", "id"), + nullable=False, + index=True, + ) + + state = Column( + Integer, + nullable=False, + default=OFFER_STATE_PENDING, + server_default=str(OFFER_STATE_PENDING), + ) + current_amount_in_cents = Column(BigInteger, nullable=False) + current_party = Column( + Integer, + nullable=False, + default=OFFER_PARTY_SELLER, # buyer just opened → seller's turn + server_default=str(OFFER_PARTY_SELLER), + ) + + created_timestamp = Column(BigInteger, nullable=False) + last_action_timestamp = Column(BigInteger, nullable=False) + expires_timestamp = Column(BigInteger, nullable=False) + paid_timestamp = Column(BigInteger, nullable=True) + + round_count = Column( + Integer, nullable=False, default=0, server_default="0" + ) + + buyer_message = Column(UnicodeText, nullable=True) + seller_message = Column(UnicodeText, nullable=True) + + product = relationship(argument="Product", uselist=False) + shop = relationship(argument="Shop", uselist=False) + buyer = relationship(argument="User", uselist=False) + + events = relationship( + argument="MpsOfferEvent", + lazy="dynamic", + order_by="MpsOfferEvent.created_timestamp.asc()", + back_populates="offer", + cascade="all, delete-orphan", + ) + + def __init__(self, product, shop, buyer, amount_in_cents, + expires_timestamp, buyer_message=None): + self.id = uuid.uuid1() + self.product = product + self.shop = shop + self.buyer = buyer + self.current_amount_in_cents = amount_in_cents + self.current_party = OFFER_PARTY_SELLER # seller responds to buyer's open + self.state = OFFER_STATE_PENDING + self.expires_timestamp = expires_timestamp + self.buyer_message = buyer_message + self.round_count = 0 + self.created_timestamp = now_timestamp() + self.last_action_timestamp = self.created_timestamp + + @property + def is_pending(self): + return self.state == OFFER_STATE_PENDING + + @property + def is_countered(self): + return self.state == OFFER_STATE_COUNTERED + + @property + def is_open(self): + """Either party still has an action to take.""" + return self.state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED) + + @property + def is_accepted(self): + return self.state == OFFER_STATE_ACCEPTED + + @property + def is_paid(self): + return self.state == OFFER_STATE_PAID + + @property + def is_terminal(self): + return self.state in OFFER_TERMINAL_STATES + + @property + def state_human(self): + return OFFER_STATE_INT_TO_HUMAN.get(self.state, "Unknown") + + @property + def time_remaining_ms(self): + return max(0, self.expires_timestamp - now_timestamp()) + + @property + def is_expired(self): + """True when expires_timestamp is past, regardless of state column. + The tick job is responsible for flipping state to OFFER_STATE_EXPIRED; + this property reports clock truth even before the tick runs.""" + return self.time_remaining_ms == 0 and not self.is_terminal + + @property + def waiting_on_buyer(self): + return self.is_open and self.current_party == OFFER_PARTY_BUYER + + @property + def waiting_on_seller(self): + return self.is_open and self.current_party == OFFER_PARTY_SELLER + + @property + def current_amount(self): + return cents_to_dollars(self.current_amount_in_cents) + + +class MpsOfferEvent(RBase, Base): + """Audit log for an offer — every action gets a row.""" + + id = Column(UUIDType, primary_key=True, index=True) + offer_id = Column( + UUIDType, + foreign_key("MpsOffer", "id"), + nullable=False, + index=True, + ) + actor_user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True) + event_type = Column(Integer, nullable=False) + amount_in_cents = Column(BigInteger, nullable=True) + message = Column(UnicodeText, nullable=True) + created_timestamp = Column(BigInteger, nullable=False) + + offer = relationship(argument="MpsOffer", uselist=False, back_populates="events") + actor = relationship(argument="User", uselist=False) + + def __init__(self, offer, event_type, actor=None, amount_in_cents=None, + message=None): + self.id = uuid.uuid1() + self.offer = offer + self.event_type = event_type + self.actor = actor + self.amount_in_cents = amount_in_cents + self.message = message + self.created_timestamp = now_timestamp() + + @property + def event_type_human(self): + return OFFER_EVENT_INT_TO_HUMAN.get(self.event_type, "Unknown") + + @property + def amount(self): + if self.amount_in_cents is None: + return None + return cents_to_dollars(self.amount_in_cents) + + +def get_offer_by_id(dbsession, offer_id): + return get_object_by_id(dbsession, offer_id, MpsOffer) diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index ac92597..efcd351 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -131,6 +131,17 @@ class Product(RBase, Base): price_in_cents = Column(BigInteger, nullable=False, default=0) + # MPS-20 / MPS-21: how this product's price is determined. + # 0=fixed (price_in_cents), 1=auction, 2=auction+buy_now, + # 3=offer, 4=offer+buy_now. + pricing_mode = Column( + Integer, nullable=False, default=0, server_default="0" + ) + + # MPS-21: per-product override of shop's offer setting. + # NULL = inherit shop, True/False = override. + allow_offers = Column(Boolean, nullable=True) + created_timestamp = Column(BigInteger, nullable=False) updated_timestamp = Column(BigInteger, nullable=False) @@ -201,6 +212,36 @@ class Product(RBase, Base): self.created_timestamp = now_timestamp() self.updated_timestamp = now_timestamp() + @property + def is_fixed_price(self): + """pricing_mode=0 — buy now at price_in_cents (today's behavior).""" + return self.pricing_mode == 0 + + @property + def is_auction(self): + """pricing_mode=1 or 2 — auction (with or without buy_now).""" + return self.pricing_mode in (1, 2) + + @property + def is_buy_now_allowed(self): + """pricing_mode in {0, 2, 4} permits direct purchase at listed price.""" + return self.pricing_mode in (0, 2, 4) + + @property + def is_offer_mode(self): + """pricing_mode=3 or 4 — make-an-offer enabled (with or without buy_now).""" + return self.pricing_mode in (3, 4) + + @property + def offers_allowed(self): + """Resolves shop default + per-product override. + allow_offers=True/False overrides; NULL inherits shop.offer_enabled.""" + if not self.is_offer_mode: + return False + if self.allow_offers is not None: + return self.allow_offers + return bool(getattr(self.shop, "offer_enabled", False)) + @property def human_updated_timestamp(self): return timestamp_to_ago_string(self.updated_timestamp) diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 9cbf5d8..ff0ef0a 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -190,6 +190,25 @@ class Shop(RBase, Base): # Torrent / magnet link distribution (off by default) torrent_enabled = Column(Boolean, default=False) + # MPS-21: make-an-offer settings (all off / sane defaults) + offer_enabled = Column(Boolean, default=False) + offer_min_in_cents = Column(BigInteger, nullable=True) + offer_auto_accept_threshold_pct = Column( + BigInteger, nullable=False, default=95, server_default="95" + ) + offer_auto_decline_threshold_pct = Column( + BigInteger, nullable=False, default=50, server_default="50" + ) + offer_expiration_hours = Column( + BigInteger, nullable=False, default=168, server_default="168" + ) + offer_max_rounds = Column( + BigInteger, nullable=False, default=3, server_default="3" + ) + offer_min_buyer_account_age_hours = Column( + BigInteger, nullable=False, default=0, server_default="0" + ) + # Precomputed discovery ring: circular ordering of all public products json_discovery_ring = Column(UnicodeText, nullable=True) diff --git a/make_post_sell/scripts/alembic/versions/f0213a54cf7b_mps_20_mps_21_pricing_mode_auction_and_.py b/make_post_sell/scripts/alembic/versions/f0213a54cf7b_mps_20_mps_21_pricing_mode_auction_and_.py new file mode 100644 index 0000000..429858d --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/f0213a54cf7b_mps_20_mps_21_pricing_mode_auction_and_.py @@ -0,0 +1,234 @@ +"""MPS-20 + MPS-21: pricing_mode, auction and offer tables + +Revision ID: f0213a54cf7b +Revises: 1a2b3c4d5e6f +Create Date: 2026-05-09 18:11:18.572145 + +Foundation for auction-house mode (MPS-20) and make-an-offer mode (MPS-21): +- Adds pricing_mode + allow_offers to mps_product +- Adds 7 offer-* settings columns to mps_shop +- Creates mps_auction, mps_bid, mps_auction_watcher tables +- Creates mps_offer, mps_offer_event tables + +Idempotent — make init-db creates tables from models, so this migration +must skip create_table / add_column when the target already exists. +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +revision = 'f0213a54cf7b' +down_revision = '1a2b3c4d5e6f' +branch_labels = None +depends_on = None + + +def _table_exists(name): + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"), + {"name": name}, + ) + return result.fetchone() is not 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()) + + +UUID = sqlalchemy_utils.types.uuid.UUIDType(binary=False) + + +def upgrade(): + # mps_product: pricing_mode + allow_offers + if not _column_exists("mps_product", "pricing_mode"): + op.add_column( + "mps_product", + sa.Column( + "pricing_mode", + sa.Integer(), + server_default="0", + nullable=False, + ), + ) + if not _column_exists("mps_product", "allow_offers"): + op.add_column( + "mps_product", + sa.Column("allow_offers", sa.Boolean(), nullable=True), + ) + + # mps_shop: offer-* columns (7). + shop_offer_columns = [ + ("offer_enabled", sa.Column("offer_enabled", sa.Boolean(), nullable=True)), + ("offer_min_in_cents", sa.Column("offer_min_in_cents", sa.BigInteger(), nullable=True)), + ("offer_auto_accept_threshold_pct", sa.Column("offer_auto_accept_threshold_pct", sa.BigInteger(), server_default="95", nullable=False)), + ("offer_auto_decline_threshold_pct", sa.Column("offer_auto_decline_threshold_pct", sa.BigInteger(), server_default="50", nullable=False)), + ("offer_expiration_hours", sa.Column("offer_expiration_hours", sa.BigInteger(), server_default="168", nullable=False)), + ("offer_max_rounds", sa.Column("offer_max_rounds", sa.BigInteger(), server_default="3", nullable=False)), + ("offer_min_buyer_account_age_hours", sa.Column("offer_min_buyer_account_age_hours", sa.BigInteger(), server_default="0", nullable=False)), + ] + for col_name, col in shop_offer_columns: + if not _column_exists("mps_shop", col_name): + op.add_column("mps_shop", col) + + # mps_auction + if not _table_exists("mps_auction"): + op.create_table( + "mps_auction", + sa.Column("id", UUID, nullable=False), + sa.Column("product_id", UUID, nullable=False), + sa.Column("shop_id", UUID, nullable=False), + sa.Column("state", sa.Integer(), server_default="0", nullable=False), + sa.Column("start_timestamp", sa.BigInteger(), nullable=True), + sa.Column("end_timestamp", sa.BigInteger(), nullable=True), + sa.Column("original_end_timestamp", sa.BigInteger(), nullable=True), + sa.Column("start_price_in_cents", sa.BigInteger(), nullable=False), + sa.Column("reserve_price_in_cents", sa.BigInteger(), nullable=True), + sa.Column("buy_now_price_in_cents", sa.BigInteger(), nullable=True), + sa.Column("bid_increment_in_cents", sa.BigInteger(), nullable=False), + sa.Column("soft_close_seconds", sa.Integer(), nullable=False), + sa.Column("winner_user_id", UUID, nullable=True), + sa.Column("winning_bid_id", UUID, nullable=True), + sa.Column("payment_deadline_timestamp", sa.BigInteger(), nullable=True), + sa.Column("currency", sa.Unicode(length=3), nullable=False), + sa.Column("created_timestamp", sa.BigInteger(), nullable=False), + sa.Column("updated_timestamp", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(["product_id"], ["mps_product.id"]), + sa.ForeignKeyConstraint(["shop_id"], ["mps_shop.id"]), + sa.ForeignKeyConstraint(["winner_user_id"], ["mps_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mps_auction_id", "mps_auction", ["id"]) + op.create_index("ix_mps_auction_product_id", "mps_auction", ["product_id"], unique=True) + op.create_index("ix_mps_auction_shop_id", "mps_auction", ["shop_id"]) + + # mps_bid + if not _table_exists("mps_bid"): + op.create_table( + "mps_bid", + sa.Column("id", UUID, nullable=False), + sa.Column("auction_id", UUID, nullable=False), + sa.Column("bidder_user_id", UUID, nullable=False), + sa.Column("amount_in_cents", sa.BigInteger(), nullable=False), + sa.Column("max_proxy_in_cents", sa.BigInteger(), nullable=True), + sa.Column("created_timestamp", sa.BigInteger(), nullable=False), + sa.Column("outbid_timestamp", sa.BigInteger(), nullable=True), + sa.Column("is_winning", sa.Boolean(), server_default="0", nullable=False), + sa.ForeignKeyConstraint(["auction_id"], ["mps_auction.id"]), + sa.ForeignKeyConstraint(["bidder_user_id"], ["mps_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mps_bid_id", "mps_bid", ["id"]) + op.create_index("ix_mps_bid_auction_id", "mps_bid", ["auction_id"]) + op.create_index("ix_mps_bid_bidder_user_id", "mps_bid", ["bidder_user_id"]) + + # mps_auction_watcher + if not _table_exists("mps_auction_watcher"): + op.create_table( + "mps_auction_watcher", + sa.Column("id", UUID, nullable=False), + sa.Column("auction_id", UUID, nullable=False), + sa.Column("user_id", UUID, nullable=False), + sa.Column("created_timestamp", sa.BigInteger(), nullable=False), + sa.Column("notify_on_outbid", sa.Boolean(), server_default="1", nullable=False), + sa.Column("notify_on_ending_soon", sa.Boolean(), server_default="1", nullable=False), + sa.ForeignKeyConstraint(["auction_id"], ["mps_auction.id"]), + sa.ForeignKeyConstraint(["user_id"], ["mps_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mps_auction_watcher_id", "mps_auction_watcher", ["id"]) + op.create_index("ix_mps_auction_watcher_auction_id", "mps_auction_watcher", ["auction_id"]) + op.create_index("ix_mps_auction_watcher_user_id", "mps_auction_watcher", ["user_id"]) + + # mps_offer + if not _table_exists("mps_offer"): + op.create_table( + "mps_offer", + sa.Column("id", UUID, nullable=False), + sa.Column("product_id", UUID, nullable=False), + sa.Column("shop_id", UUID, nullable=False), + sa.Column("buyer_user_id", UUID, nullable=False), + sa.Column("state", sa.Integer(), server_default="0", nullable=False), + sa.Column("current_amount_in_cents", sa.BigInteger(), nullable=False), + sa.Column("current_party", sa.Integer(), server_default="1", nullable=False), + sa.Column("created_timestamp", sa.BigInteger(), nullable=False), + sa.Column("last_action_timestamp", sa.BigInteger(), nullable=False), + sa.Column("expires_timestamp", sa.BigInteger(), nullable=False), + sa.Column("paid_timestamp", sa.BigInteger(), nullable=True), + sa.Column("round_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("buyer_message", sa.UnicodeText(), nullable=True), + sa.Column("seller_message", sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint(["buyer_user_id"], ["mps_user.id"]), + sa.ForeignKeyConstraint(["product_id"], ["mps_product.id"]), + sa.ForeignKeyConstraint(["shop_id"], ["mps_shop.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mps_offer_id", "mps_offer", ["id"]) + op.create_index("ix_mps_offer_product_id", "mps_offer", ["product_id"]) + op.create_index("ix_mps_offer_shop_id", "mps_offer", ["shop_id"]) + op.create_index("ix_mps_offer_buyer_user_id", "mps_offer", ["buyer_user_id"]) + + # mps_offer_event + if not _table_exists("mps_offer_event"): + op.create_table( + "mps_offer_event", + sa.Column("id", UUID, nullable=False), + sa.Column("offer_id", UUID, nullable=False), + sa.Column("actor_user_id", UUID, nullable=True), + sa.Column("event_type", sa.Integer(), nullable=False), + sa.Column("amount_in_cents", sa.BigInteger(), nullable=True), + sa.Column("message", sa.UnicodeText(), nullable=True), + sa.Column("created_timestamp", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["mps_user.id"]), + sa.ForeignKeyConstraint(["offer_id"], ["mps_offer.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mps_offer_event_id", "mps_offer_event", ["id"]) + op.create_index("ix_mps_offer_event_offer_id", "mps_offer_event", ["offer_id"]) + + +def downgrade(): + if _table_exists("mps_offer_event"): + op.drop_index("ix_mps_offer_event_offer_id", table_name="mps_offer_event") + op.drop_index("ix_mps_offer_event_id", table_name="mps_offer_event") + op.drop_table("mps_offer_event") + if _table_exists("mps_offer"): + op.drop_index("ix_mps_offer_buyer_user_id", table_name="mps_offer") + op.drop_index("ix_mps_offer_shop_id", table_name="mps_offer") + op.drop_index("ix_mps_offer_product_id", table_name="mps_offer") + op.drop_index("ix_mps_offer_id", table_name="mps_offer") + op.drop_table("mps_offer") + if _table_exists("mps_auction_watcher"): + op.drop_index("ix_mps_auction_watcher_user_id", table_name="mps_auction_watcher") + op.drop_index("ix_mps_auction_watcher_auction_id", table_name="mps_auction_watcher") + op.drop_index("ix_mps_auction_watcher_id", table_name="mps_auction_watcher") + op.drop_table("mps_auction_watcher") + if _table_exists("mps_bid"): + op.drop_index("ix_mps_bid_bidder_user_id", table_name="mps_bid") + op.drop_index("ix_mps_bid_auction_id", table_name="mps_bid") + op.drop_index("ix_mps_bid_id", table_name="mps_bid") + op.drop_table("mps_bid") + if _table_exists("mps_auction"): + op.drop_index("ix_mps_auction_shop_id", table_name="mps_auction") + op.drop_index("ix_mps_auction_product_id", table_name="mps_auction") + op.drop_index("ix_mps_auction_id", table_name="mps_auction") + op.drop_table("mps_auction") + + for col in ( + "offer_min_buyer_account_age_hours", + "offer_max_rounds", + "offer_expiration_hours", + "offer_auto_decline_threshold_pct", + "offer_auto_accept_threshold_pct", + "offer_min_in_cents", + "offer_enabled", + ): + if _column_exists("mps_shop", col): + op.drop_column("mps_shop", col) + if _column_exists("mps_product", "allow_offers"): + op.drop_column("mps_product", "allow_offers") + if _column_exists("mps_product", "pricing_mode"): + op.drop_column("mps_product", "pricing_mode") diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 2a2f67e..ce23c0a 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -3991,3 +3991,375 @@ class TestBYOBIntegration(DatabaseIntegrationTests): self.assertEqual(fetched.primary_s3_bucket, "test-bucket") self.assertEqual(fetched.primary_s3_cdn_endpoint, "https://cdn.example.com") transaction.commit() + + +class TestAuctionFoundation(DatabaseIntegrationTests): + """MPS-20: persist auction + bids + watchers, exercise relationships.""" + + def _make_shop_and_product(self, pricing_mode=1): + from ..models.product import Product + shop = Shop( + name="Auction House", + phone_number="555-555-0001", + billing_address="1 auction lane", + description="auction shop", + ) + shop.stripe_public_api_key = "pk_test" + shop.stripe_secret_api_key = "sk_test" + shop.domain_name = "auction.test" + self.dbsession.add(shop) + + product = Product(title="Rare item", description="One of a kind") + product.shop = shop + product.price_in_cents = 10000 + product.is_physical = False + product.is_sellable = True + product.pricing_mode = pricing_mode + self.dbsession.add(product) + self.dbsession.flush() + return shop, product + + def test_create_and_persist_auction(self): + from ..models.auction import ( + MpsAuction, AUCTION_STATE_DRAFT, AUCTION_STATE_ACTIVE, + now_timestamp, get_auction_by_id, + ) + shop, product = self._make_shop_and_product(pricing_mode=1) + + auction = MpsAuction( + product=product, shop=shop, start_price_in_cents=5000, + bid_increment_in_cents=100, soft_close_seconds=60, + ) + self.dbsession.add(auction) + self.dbsession.flush() + + self.assertEqual(auction.state, AUCTION_STATE_DRAFT) + self.assertTrue(auction.is_draft) + self.assertFalse(auction.is_terminal) + self.assertEqual(auction.start_price_in_cents, 5000) + self.assertEqual(auction.bid_increment_in_cents, 100) + self.assertEqual(auction.currency, "USD") + auction_id = auction.id + transaction.commit() + + fetched = get_auction_by_id(self.dbsession, auction_id) + self.assertIsNotNone(fetched) + self.assertEqual(fetched.start_price_in_cents, 5000) + self.assertEqual(fetched.product.title, "Rare item") + self.assertEqual(fetched.shop.name, "Auction House") + + # Verify Product.auction backref is wired. + self.assertIs(fetched.product.auction, fetched) + + # Mutate and round-trip. + fetched.state = AUCTION_STATE_ACTIVE + fetched.start_timestamp = now_timestamp() + fetched.end_timestamp = now_timestamp() + 3_600_000 + transaction.commit() + + fetched2 = get_auction_by_id(self.dbsession, auction_id) + self.assertTrue(fetched2.is_active) + + def test_one_auction_per_product_unique_constraint(self): + from ..models.auction import MpsAuction + from sqlalchemy.exc import IntegrityError + shop, product = self._make_shop_and_product(pricing_mode=1) + + a1 = MpsAuction(product=product, shop=shop, start_price_in_cents=1000) + self.dbsession.add(a1) + self.dbsession.flush() + transaction.commit() + + # Second auction for the same product violates unique index on product_id. + a2 = MpsAuction(product=product, shop=shop, start_price_in_cents=2000) + self.dbsession.add(a2) + with self.assertRaises(IntegrityError): + self.dbsession.flush() + transaction.abort() + + def test_bids_relationship_and_current_high(self): + from ..models.auction import MpsAuction, MpsBid + shop, product = self._make_shop_and_product(pricing_mode=1) + u1 = get_or_create_user_by_email(self.dbsession, "bidder1@example.com") + u2 = get_or_create_user_by_email(self.dbsession, "bidder2@example.com") + self.dbsession.add(u1) + self.dbsession.add(u2) + self.dbsession.flush() + + auction = MpsAuction( + product=product, shop=shop, start_price_in_cents=1000, + ) + self.dbsession.add(auction) + self.dbsession.flush() + + # No bids: current high = start price. + self.assertEqual(auction.current_high_in_cents, 1000) + self.assertEqual(auction.min_next_bid_in_cents, 1000) + + b1 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1100) + b2 = MpsBid(auction=auction, bidder=u2, amount_in_cents=1300) + b3 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1500) + for b in (b1, b2, b3): + self.dbsession.add(b) + self.dbsession.flush() + + self.assertEqual(auction.bids.count(), 3) + self.assertEqual(auction.current_high_in_cents, 1500) + self.assertEqual(auction.min_next_bid_in_cents, 1500 + auction.bid_increment_in_cents) + transaction.commit() + + def test_reserve_met_logic(self): + from ..models.auction import MpsAuction, MpsBid + shop, product = self._make_shop_and_product(pricing_mode=1) + u1 = get_or_create_user_by_email(self.dbsession, "reserve_bidder@example.com") + self.dbsession.add(u1) + self.dbsession.flush() + + auction = MpsAuction( + product=product, shop=shop, start_price_in_cents=1000, + ) + auction.reserve_price_in_cents = 5000 + self.dbsession.add(auction) + self.dbsession.flush() + + self.assertTrue(auction.has_reserve) + # No bids → reserve not met. + self.assertFalse(auction.reserve_met) + + # Bid below reserve. + self.dbsession.add( + MpsBid(auction=auction, bidder=u1, amount_in_cents=4000) + ) + self.dbsession.flush() + self.assertFalse(auction.reserve_met) + + # Bid at/above reserve. + self.dbsession.add( + MpsBid(auction=auction, bidder=u1, amount_in_cents=5000) + ) + self.dbsession.flush() + self.assertTrue(auction.reserve_met) + transaction.commit() + + def test_watcher_relationship_and_cascade_delete(self): + from ..models.auction import ( + MpsAuction, MpsBid, MpsAuctionWatcher, get_auction_by_id, + ) + shop, product = self._make_shop_and_product(pricing_mode=1) + watcher_user = get_or_create_user_by_email(self.dbsession, "w@example.com") + bidder_user = get_or_create_user_by_email(self.dbsession, "b@example.com") + self.dbsession.add(watcher_user) + self.dbsession.add(bidder_user) + self.dbsession.flush() + + auction = MpsAuction(product=product, shop=shop, start_price_in_cents=100) + self.dbsession.add(auction) + self.dbsession.flush() + + watcher = MpsAuctionWatcher(auction=auction, user=watcher_user) + bid = MpsBid(auction=auction, bidder=bidder_user, amount_in_cents=200) + self.dbsession.add(watcher) + self.dbsession.add(bid) + self.dbsession.flush() + auction_id = auction.id + transaction.commit() + + # Re-query after commit (session is fresh). + fetched = get_auction_by_id(self.dbsession, auction_id) + self.assertEqual(fetched.watchers.count(), 1) + self.assertEqual(fetched.bids.count(), 1) + + # Delete auction → bids and watchers cascade. + self.dbsession.delete(fetched) + self.dbsession.flush() + self.assertEqual(self.dbsession.query(MpsBid).count(), 0) + self.assertEqual(self.dbsession.query(MpsAuctionWatcher).count(), 0) + transaction.commit() + + +class TestOfferFoundation(DatabaseIntegrationTests): + """MPS-21: persist offer + events, exercise state + relationships.""" + + def _make_shop_and_product(self, pricing_mode=3, allow_offers=None, + offer_enabled=True): + from ..models.product import Product + shop = Shop( + name="Negotiation Shop", + phone_number="555-555-0002", + billing_address="2 offer way", + description="offers shop", + ) + shop.stripe_public_api_key = "pk_test" + shop.stripe_secret_api_key = "sk_test" + shop.domain_name = "offers.test" + shop.offer_enabled = offer_enabled + self.dbsession.add(shop) + + product = Product(title="Negotiable", description="Make me an offer") + product.shop = shop + product.price_in_cents = 10000 + product.is_physical = False + product.is_sellable = True + product.pricing_mode = pricing_mode + product.allow_offers = allow_offers + self.dbsession.add(product) + self.dbsession.flush() + return shop, product + + def test_create_and_persist_offer(self): + from ..models.offer import ( + MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN, + OFFER_STATE_PENDING, OFFER_PARTY_SELLER, + now_timestamp, get_offer_by_id, + ) + shop, product = self._make_shop_and_product() + buyer = get_or_create_user_by_email(self.dbsession, "buyer@example.com") + self.dbsession.add(buyer) + self.dbsession.flush() + + expires = now_timestamp() + 7 * 24 * 3600 * 1000 + offer = MpsOffer( + product=product, shop=shop, buyer=buyer, + amount_in_cents=8000, + expires_timestamp=expires, + buyer_message="please?", + ) + self.dbsession.add(offer) + self.dbsession.flush() + + # Open event fires up front (we'll validate state machine in lib tests + # — for now just confirm we can write & query an event row). + evt = MpsOfferEvent( + offer=offer, event_type=OFFER_EVENT_OPEN, + actor=buyer, amount_in_cents=8000, message="opened", + ) + self.dbsession.add(evt) + self.dbsession.flush() + offer_id = offer.id + transaction.commit() + + fetched = get_offer_by_id(self.dbsession, offer_id) + self.assertIsNotNone(fetched) + self.assertEqual(fetched.state, OFFER_STATE_PENDING) + self.assertEqual(fetched.current_party, OFFER_PARTY_SELLER) + self.assertEqual(fetched.current_amount_in_cents, 8000) + self.assertEqual(fetched.round_count, 0) + self.assertEqual(fetched.events.count(), 1) + self.assertEqual(fetched.product.title, "Negotiable") + self.assertTrue(fetched.is_open) + self.assertTrue(fetched.waiting_on_seller) + + def test_event_cascade_delete(self): + from ..models.offer import ( + MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER, + now_timestamp, get_offer_by_id, + ) + shop, product = self._make_shop_and_product() + buyer = get_or_create_user_by_email(self.dbsession, "buyer2@example.com") + self.dbsession.add(buyer) + self.dbsession.flush() + + offer = MpsOffer( + product=product, shop=shop, buyer=buyer, + amount_in_cents=5000, expires_timestamp=now_timestamp() + 1_000_000, + ) + self.dbsession.add(offer) + self.dbsession.flush() + for et in (OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER, OFFER_EVENT_COUNTER): + self.dbsession.add( + MpsOfferEvent(offer=offer, event_type=et, actor=buyer) + ) + self.dbsession.flush() + offer_id = offer.id + transaction.commit() + + fetched = get_offer_by_id(self.dbsession, offer_id) + self.assertEqual(fetched.events.count(), 3) + + # Delete offer → events cascade. + self.dbsession.delete(fetched) + self.dbsession.flush() + self.assertEqual(self.dbsession.query(MpsOfferEvent).count(), 0) + transaction.commit() + + +class TestPricingModePersistence(DatabaseIntegrationTests): + """MPS-20 + MPS-21: pricing_mode + allow_offers persist on Product.""" + + def test_pricing_mode_default_zero(self): + shop = Shop( + name="Default 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 = "d.test" + self.dbsession.add(shop) + + product = Product(title="Plain", description="No mode set") + product.shop = shop + product.price_in_cents = 0 + product.is_physical = False + self.dbsession.add(product) + self.dbsession.flush() + product_id = product.id + transaction.commit() + + fetched = self.dbsession.query(Product).get(product_id) + self.assertEqual(fetched.pricing_mode, 0) + self.assertTrue(fetched.is_fixed_price) + self.assertIsNone(fetched.allow_offers) + self.assertFalse(fetched.is_offer_mode) + self.assertFalse(fetched.offers_allowed) + + def test_offers_allowed_inherits_shop_offer_enabled(self): + # pricing_mode=3 + allow_offers=NULL + shop.offer_enabled=True → True + shop = Shop( + name="Offer 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 = "o.test" + shop.offer_enabled = True + self.dbsession.add(shop) + + product = Product(title="Offerable", description="...") + product.shop = shop + product.price_in_cents = 5000 + product.is_physical = False + product.pricing_mode = 3 + # allow_offers=NULL → inherit shop default. + self.dbsession.add(product) + self.dbsession.flush() + product_id = product.id + transaction.commit() + + fetched = self.dbsession.query(Product).get(product_id) + self.assertTrue(fetched.is_offer_mode) + self.assertIsNone(fetched.allow_offers) + self.assertTrue(fetched.offers_allowed) + + def test_shop_offer_settings_defaults_persist(self): + shop = Shop( + name="Settings 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 = "s.test" + # Don't set offer_* — defaults should apply on flush. + self.dbsession.add(shop) + self.dbsession.flush() + shop_id = shop.id + transaction.commit() + + fetched = self.dbsession.query(Shop).get(shop_id) + self.assertEqual(fetched.offer_auto_accept_threshold_pct, 95) + self.assertEqual(fetched.offer_auto_decline_threshold_pct, 50) + self.assertEqual(fetched.offer_expiration_hours, 168) + self.assertEqual(fetched.offer_max_rounds, 3) + self.assertEqual(fetched.offer_min_buyer_account_age_hours, 0) + # Booleans/nullable: offer_enabled defaults None (no server_default set) + # offer_min_in_cents nullable. + self.assertIsNone(fetched.offer_min_in_cents) diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 0c69ad7..90a03e4 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -4165,3 +4165,257 @@ class TestFeatureKillSwitches(unittest.TestCase): def test_torrent_bool_passthrough(self): self.assertTrue(self._torrent_resolve({"features.torrent.enabled": True})) self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False})) + + +class TestProductPricingMode(unittest.TestCase): + """MPS-20 + MPS-21: pricing_mode helper properties on Product.""" + + def _make_product(self, pricing_mode=0, allow_offers=None, + shop_offer_enabled=False): + """Stub Product/Shop with only the attributes pricing_mode helpers need. + Avoids spinning up SQLAlchemy mappers (Mock(spec=Shop) would import them).""" + from types import SimpleNamespace + from ..models.product import Product + + product = SimpleNamespace( + pricing_mode=pricing_mode, + allow_offers=allow_offers, + shop=SimpleNamespace(offer_enabled=shop_offer_enabled), + ) + # Bind real properties for direct testing. + product.is_fixed_price = Product.is_fixed_price.fget(product) + product.is_auction = Product.is_auction.fget(product) + product.is_buy_now_allowed = Product.is_buy_now_allowed.fget(product) + product.is_offer_mode = Product.is_offer_mode.fget(product) + product.offers_allowed = Product.offers_allowed.fget(product) + return product + + def test_fixed_mode_zero(self): + p = self._make_product(pricing_mode=0) + self.assertTrue(p.is_fixed_price) + self.assertFalse(p.is_auction) + self.assertTrue(p.is_buy_now_allowed) + self.assertFalse(p.is_offer_mode) + + def test_auction_only_mode_one(self): + p = self._make_product(pricing_mode=1) + self.assertFalse(p.is_fixed_price) + self.assertTrue(p.is_auction) + self.assertFalse(p.is_buy_now_allowed) + self.assertFalse(p.is_offer_mode) + + def test_auction_with_buy_now_mode_two(self): + p = self._make_product(pricing_mode=2) + self.assertTrue(p.is_auction) + self.assertTrue(p.is_buy_now_allowed) + self.assertFalse(p.is_offer_mode) + + def test_offer_only_mode_three(self): + p = self._make_product(pricing_mode=3) + self.assertFalse(p.is_auction) + self.assertFalse(p.is_buy_now_allowed) + self.assertTrue(p.is_offer_mode) + + def test_offer_with_buy_now_mode_four(self): + p = self._make_product(pricing_mode=4) + self.assertTrue(p.is_offer_mode) + self.assertTrue(p.is_buy_now_allowed) + + def test_offers_allowed_inherits_shop_when_override_null(self): + p = self._make_product( + pricing_mode=3, allow_offers=None, shop_offer_enabled=True, + ) + self.assertTrue(p.offers_allowed) + + p2 = self._make_product( + pricing_mode=3, allow_offers=None, shop_offer_enabled=False, + ) + self.assertFalse(p2.offers_allowed) + + def test_offers_allowed_per_product_override_wins(self): + # Override True even when shop is False. + p = self._make_product( + pricing_mode=3, allow_offers=True, shop_offer_enabled=False, + ) + self.assertTrue(p.offers_allowed) + + # Override False even when shop is True. + p2 = self._make_product( + pricing_mode=3, allow_offers=False, shop_offer_enabled=True, + ) + self.assertFalse(p2.offers_allowed) + + def test_offers_allowed_false_outside_offer_mode(self): + # Even with override True, offers are blocked when pricing_mode != 3 or 4. + p = self._make_product( + pricing_mode=0, allow_offers=True, shop_offer_enabled=True, + ) + self.assertFalse(p.offers_allowed) + + +class TestMpsAuctionStateHelpers(unittest.TestCase): + """MPS-20: state helpers on MpsAuction (no DB; we exercise pure properties).""" + + def _make_auction(self, state=0, end_timestamp=None, + reserve_price_in_cents=None, + buy_now_price_in_cents=None, + start_price_in_cents=0): + from types import SimpleNamespace + from ..models.auction import MpsAuction + + a = SimpleNamespace( + state=state, + end_timestamp=end_timestamp, + reserve_price_in_cents=reserve_price_in_cents, + buy_now_price_in_cents=buy_now_price_in_cents, + start_price_in_cents=start_price_in_cents, + ) + for name in [ + "is_draft", "is_scheduled", "is_active", "is_ended", + "is_settled", "is_cancelled", "is_terminal", + "state_human", "time_remaining_ms", + "has_buy_now", "has_reserve", + ]: + setattr(a, name, getattr(MpsAuction, name).fget(a)) + return a + + def test_state_classifiers(self): + from ..models.auction import ( + AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE, + AUCTION_STATE_ENDED, AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED, + ) + cases = [ + (AUCTION_STATE_DRAFT, "is_draft", "Draft"), + (AUCTION_STATE_SCHEDULED, "is_scheduled", "Scheduled"), + (AUCTION_STATE_ACTIVE, "is_active", "Active"), + (AUCTION_STATE_ENDED, "is_ended", "Ended"), + (AUCTION_STATE_SETTLED, "is_settled", "Settled"), + (AUCTION_STATE_CANCELLED, "is_cancelled", "Cancelled"), + ] + for state, prop, human in cases: + a = self._make_auction(state=state) + self.assertTrue(getattr(a, prop), f"{prop} should be True for state {state}") + self.assertEqual(a.state_human, human) + + def test_terminal_states(self): + from ..models.auction import AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED + for terminal in (AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED): + self.assertTrue(self._make_auction(state=terminal).is_terminal) + for non_terminal in (0, 1, 2, 3): + self.assertFalse(self._make_auction(state=non_terminal).is_terminal) + + def test_time_remaining_ms_no_end(self): + self.assertEqual(self._make_auction(end_timestamp=None).time_remaining_ms, 0) + + def test_time_remaining_ms_past(self): + from ..models.auction import now_timestamp + past = now_timestamp() - 60_000 + self.assertEqual(self._make_auction(end_timestamp=past).time_remaining_ms, 0) + + def test_time_remaining_ms_future(self): + from ..models.auction import now_timestamp + future = now_timestamp() + 60_000 + remaining = self._make_auction(end_timestamp=future).time_remaining_ms + self.assertGreater(remaining, 50_000) + self.assertLessEqual(remaining, 60_000) + + def test_has_buy_now_and_reserve(self): + self.assertFalse(self._make_auction().has_buy_now) + self.assertFalse(self._make_auction().has_reserve) + self.assertTrue(self._make_auction(buy_now_price_in_cents=10000).has_buy_now) + self.assertTrue(self._make_auction(reserve_price_in_cents=5000).has_reserve) + + +class TestMpsOfferStateHelpers(unittest.TestCase): + """MPS-21: state helpers on MpsOffer.""" + + def _make_offer(self, state=0, current_party=1, + expires_timestamp=None, + current_amount_in_cents=1000): + from types import SimpleNamespace + from ..models.offer import MpsOffer, now_timestamp + + o = SimpleNamespace( + state=state, + current_party=current_party, + expires_timestamp=expires_timestamp or (now_timestamp() + 60_000), + current_amount_in_cents=current_amount_in_cents, + ) + for name in [ + "is_pending", "is_countered", "is_open", "is_accepted", "is_paid", + "is_terminal", "state_human", + "time_remaining_ms", "is_expired", + "waiting_on_buyer", "waiting_on_seller", + ]: + setattr(o, name, getattr(MpsOffer, name).fget(o)) + return o + + def test_state_classifiers(self): + from ..models.offer import ( + OFFER_STATE_PENDING, OFFER_STATE_COUNTERED, OFFER_STATE_ACCEPTED, + OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED, OFFER_STATE_WITHDRAWN, + OFFER_STATE_PAID, + ) + self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_pending) + self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_countered) + self.assertTrue(self._make_offer(state=OFFER_STATE_ACCEPTED).is_accepted) + self.assertTrue(self._make_offer(state=OFFER_STATE_PAID).is_paid) + # is_open: pending or countered only + self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_open) + self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_open) + self.assertFalse(self._make_offer(state=OFFER_STATE_ACCEPTED).is_open) + self.assertFalse(self._make_offer(state=OFFER_STATE_DECLINED).is_open) + # terminal: accepted/declined/expired/withdrawn/paid + for terminal in ( + OFFER_STATE_ACCEPTED, OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED, + OFFER_STATE_WITHDRAWN, OFFER_STATE_PAID, + ): + self.assertTrue(self._make_offer(state=terminal).is_terminal) + for non_terminal in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED): + self.assertFalse(self._make_offer(state=non_terminal).is_terminal) + + def test_waiting_on_party(self): + from ..models.offer import ( + OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, + OFFER_STATE_PENDING, OFFER_STATE_ACCEPTED, + ) + # Open offer → exactly one party is waiting + buyer_turn = self._make_offer( + state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_BUYER, + ) + self.assertTrue(buyer_turn.waiting_on_buyer) + self.assertFalse(buyer_turn.waiting_on_seller) + + seller_turn = self._make_offer( + state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_SELLER, + ) + self.assertFalse(seller_turn.waiting_on_buyer) + self.assertTrue(seller_turn.waiting_on_seller) + + # Terminal offer → nobody waiting + terminal = self._make_offer( + state=OFFER_STATE_ACCEPTED, current_party=OFFER_PARTY_BUYER, + ) + self.assertFalse(terminal.waiting_on_buyer) + self.assertFalse(terminal.waiting_on_seller) + + def test_time_remaining_and_expired(self): + from ..models.offer import ( + now_timestamp, OFFER_STATE_PENDING, OFFER_STATE_PAID, + ) + future = now_timestamp() + 60_000 + past = now_timestamp() - 60_000 + + live = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=future) + self.assertGreater(live.time_remaining_ms, 50_000) + self.assertFalse(live.is_expired) + + dead = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=past) + self.assertEqual(dead.time_remaining_ms, 0) + self.assertTrue(dead.is_expired) + + # is_expired only fires for non-terminal offers + terminal_past = self._make_offer( + state=OFFER_STATE_PAID, expires_timestamp=past, + ) + self.assertFalse(terminal_past.is_expired)