MPS-20 + MPS-21: cart integration — checkout, total override, settle hook

Closes the payment loop for both auction and offer modes. After this
commit, an auction winner can pay the agreed bid amount; an offer-
accepted buyer can pay the agreed offer amount. Cart total computation
uses the auction's winning_bid or offer.current_amount when an
association is present.

New tables (idempotent migration):
- mps_cart_auction (cart_id ↔ auction_id) — cart total uses winning bid
- mps_cart_offer   (cart_id ↔ offer_id)   — cart total uses agreed amount

Cart.auction_offer_override_in_cents property returns the override
amount when either association is set; None otherwise. total_price_in_cents
short-circuits to the override + handling + gift-card-purchases when an
override is present.

Routes:
- POST /a/{auction_id}/checkout  winner only; auction must be ENDED;
  builds a fresh cart, adds product, creates MpsCartAuction
- POST /o/{offer_id}/checkout    buyer only; offer must be ACCEPTED;
  builds a fresh cart, adds product, creates MpsCartOffer

Both checkouts redirect to /cart on success or back to /a/{id} or
/o/{id} on rejection (with flash message).

Settle hook in views/cart.py: after invoice is paid, _finalize_auction_offer_state
flips auction.state=SETTLED (recording winner_user_id + winning_bid_id)
and calls lib/offer.mark_paid which transitions offer ACCEPTED → PAID
and writes the OFFER_EVENT_PAY audit row.

Tests:
- 4 integration: cart-auction + cart-offer associations persist + cascade
- 3 integration: cart total override (no association → list price;
  with auction → winning bid; with offer → agreed amount)
- 3 functional auction checkout: winner builds cart with override,
  non-winner blocked, active auction blocked
- 3 functional offer checkout: buyer builds cart with override,
  non-buyer blocked, pending offer blocked

Total: 926 tests pass (was 913 + 13).
This commit is contained in:
russell@unturf.com 2026-05-09 20:40:54 -04:00
parent 17ce2ae173
commit 3c9fc5544b
No known key found for this signature in database
12 changed files with 793 additions and 0 deletions

View file

@ -40,6 +40,8 @@ from .api_key import *
from .auction import *
from .offer import *
from .cart_auction import *
from .cart_offer import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.

View file

@ -342,11 +342,42 @@ class Cart(RBase, Base):
)
return self._discounted_shop_totals
@property
def auction_offer_override_in_cents(self):
"""MPS-20 + MPS-21: when this cart is linked to a winning auction
or accepted offer, the agreed amount overrides product list price.
Returns the override in cents, or None if no association.
At most one of cart_auctions or cart_offers should be set per cart;
if both are set (defensive should not happen), the auction wins.
"""
if self.cart_auctions:
ca = self.cart_auctions[0]
winning_bid = (
ca.auction.bids.filter_by(is_winning=True).one_or_none()
)
if winning_bid is not None:
return winning_bid.amount_in_cents
return ca.auction.start_price_in_cents
if self.cart_offers:
return self.cart_offers[0].offer.current_amount_in_cents
return None
@property
def total_price_in_cents(self):
"""
Calculate the total price in cents, including handling cost and gift card purchases.
MPS-20 + MPS-21: an auction-won or offer-accepted cart pays the
agreed amount instead of the sum of line items.
"""
override = self.auction_offer_override_in_cents
if override is not None:
total = override
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
total = sum(self.line_totals_in_cents.values())
total += self.gift_card_purchases_total_in_cents
if self.handling_cost_in_cents:

View file

@ -0,0 +1,40 @@
"""MpsCartAuction — association between a cart and the auction whose
winning bid drives its total.
A cart with a cart_auction row pays at the auction's winning_bid amount,
not the product's listed price. The route that creates this association
(/a/{id}/checkout commit 8) verifies the user is the winner.
The relationship is many-to-one to Cart and one-to-one to MpsAuction
(an auction has at most one cart in the wild once paid, state flips
to SETTLED and the association sticks for audit but no other cart is
created against the same auction).
"""
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship, backref
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class MpsCartAuction(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=False)
auction_id = Column(
UUIDType, foreign_key("MpsAuction", "id"), nullable=False, index=True,
)
created_timestamp = Column(BigInteger, nullable=False)
cart = relationship(
argument="Cart",
backref=backref("cart_auctions", cascade="all, delete-orphan"),
)
auction = relationship(argument="MpsAuction")
def __init__(self, cart=None, auction=None):
self.id = uuid.uuid1()
self.cart = cart
self.auction = auction
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,38 @@
"""MpsCartOffer — association between a cart and the accepted offer
whose agreed amount drives its total.
A cart with a cart_offer row pays at the offer's current_amount_in_cents,
not the product's listed price. The route that creates this association
(/o/{id}/checkout commit 8) verifies the offer is in ACCEPTED state
and the user is the buyer.
Many-to-one to Cart, one-to-one to MpsOffer.
"""
import uuid
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship, backref
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class MpsCartOffer(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=False)
offer_id = Column(
UUIDType, foreign_key("MpsOffer", "id"), nullable=False, index=True,
)
created_timestamp = Column(BigInteger, nullable=False)
cart = relationship(
argument="Cart",
backref=backref("cart_offers", cascade="all, delete-orphan"),
)
offer = relationship(argument="MpsOffer")
def __init__(self, cart=None, offer=None):
self.id = uuid.uuid1()
self.cart = cart
self.offer = offer
self.created_timestamp = now_timestamp()

View file

@ -52,6 +52,8 @@ CLASS_TO_TABLE = {
"MpsAuctionWatcher": "mps_auction_watcher",
"MpsOffer": "mps_offer",
"MpsOfferEvent": "mps_offer_event",
"MpsCartAuction": "mps_cart_auction",
"MpsCartOffer": "mps_cart_offer",
}

View file

@ -245,6 +245,7 @@ def includeme(config):
config.add_route("auction_bid", "/a/{auction_id}/bid")
config.add_route("auction_buy_now", "/a/{auction_id}/buy-now")
config.add_route("auction_watch", "/a/{auction_id}/watch")
config.add_route("auction_checkout", "/a/{auction_id}/checkout")
config.add_route("auction_page", "/a/{auction_id}")
# Offers (MPS-21). offer_open is registered earlier near product routes
@ -253,4 +254,5 @@ def includeme(config):
config.add_route("offer_accept", "/o/{offer_id}/accept")
config.add_route("offer_decline", "/o/{offer_id}/decline")
config.add_route("offer_withdraw", "/o/{offer_id}/withdraw")
config.add_route("offer_checkout", "/o/{offer_id}/checkout")
config.add_route("offer_page", "/o/{offer_id}")

View file

@ -0,0 +1,82 @@
"""MPS-20 + MPS-21: cart-auction + cart-offer association tables
Revision ID: 34e1c65d0bea
Revises: f0213a54cf7b
Create Date: 2026-05-09 20:10:46.799472
Adds two association tables that override Cart.total_in_cents:
- mps_cart_auction links a cart to a winning auction; cart total uses
the auction's winning bid amount instead of product list price.
- mps_cart_offer links a cart to an accepted offer; cart total uses
the offer's current_amount_in_cents instead of product list price.
Idempotent make init-db creates tables from models.
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
revision = '34e1c65d0bea'
down_revision = 'f0213a54cf7b'
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
UUID = sqlalchemy_utils.types.uuid.UUIDType(binary=False)
def upgrade():
if not _table_exists("mps_cart_auction"):
op.create_table(
"mps_cart_auction",
sa.Column("id", UUID, nullable=False),
sa.Column("cart_id", UUID, nullable=False),
sa.Column("auction_id", UUID, nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["auction_id"], ["mps_auction.id"]),
sa.ForeignKeyConstraint(["cart_id"], ["mps_cart.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_cart_auction_id", "mps_cart_auction", ["id"])
op.create_index(
"ix_mps_cart_auction_auction_id", "mps_cart_auction", ["auction_id"],
)
if not _table_exists("mps_cart_offer"):
op.create_table(
"mps_cart_offer",
sa.Column("id", UUID, nullable=False),
sa.Column("cart_id", UUID, nullable=False),
sa.Column("offer_id", UUID, nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["cart_id"], ["mps_cart.id"]),
sa.ForeignKeyConstraint(["offer_id"], ["mps_offer.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_cart_offer_id", "mps_cart_offer", ["id"])
op.create_index(
"ix_mps_cart_offer_offer_id", "mps_cart_offer", ["offer_id"],
)
def downgrade():
if _table_exists("mps_cart_offer"):
op.drop_index("ix_mps_cart_offer_offer_id", table_name="mps_cart_offer")
op.drop_index("ix_mps_cart_offer_id", table_name="mps_cart_offer")
op.drop_table("mps_cart_offer")
if _table_exists("mps_cart_auction"):
op.drop_index(
"ix_mps_cart_auction_auction_id", table_name="mps_cart_auction",
)
op.drop_index("ix_mps_cart_auction_id", table_name="mps_cart_auction")
op.drop_table("mps_cart_auction")

View file

@ -6154,3 +6154,227 @@ class TestPricingModeFormSection(_AuthenticatedBase):
f"/p/{product_id}/{product_slug}", status=200,
)
self.assertNotIn(b"Make an offer", res.body)
class TestAuctionCheckout(_AuthenticatedBase):
"""MPS-20: /a/{id}/checkout creates a cart linked to the auction so
/cart shows the winning bid amount as total."""
def _ended_auction_with_winner(self):
"""user2 wins an auction owned by user1."""
from ..models.auction import (
MpsAuction, MpsBid, AUCTION_STATE_ENDED, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Won prize", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1
# Make it ready (extensions.product set).
import json
product.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"product": "pdf"}},
)
self.dbsession.add(product)
self.dbsession.flush()
from ..models.user import get_or_create_user_by_email
winner = get_or_create_user_by_email(
self.dbsession, "test2@example.com",
)
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ENDED
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1_000
auction.winner = winner
self.dbsession.add(auction)
bid = MpsBid(auction=auction, bidder=winner, amount_in_cents=3700)
bid.is_winning = True
self.dbsession.add(bid)
self.dbsession.flush()
auction_id = auction.uuid_str
transaction.commit()
return auction_id
def test_winner_checkout_creates_cart_with_override(self):
auction_id = self._ended_auction_with_winner()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# POST /a/{id}/checkout redirects to /cart.
self.testapp.post(f"/a/{auction_id}/checkout", status=302)
# The user's active cart now has the cart_auction association.
from ..models.cart import Cart
from ..models.cart_auction import MpsCartAuction
from ..models.user import get_or_create_user_by_email
winner = get_or_create_user_by_email(self.dbsession, "test2@example.com")
active_carts = (
self.dbsession.query(Cart)
.filter(Cart.user_id == winner.id, Cart.active.is_(True))
.all()
)
# At least one active cart with an MpsCartAuction row.
ca_count = self.dbsession.query(MpsCartAuction).count()
self.assertGreaterEqual(ca_count, 1)
# That cart's total uses the winning bid amount.
match = [c for c in active_carts if c.cart_auctions]
self.assertEqual(len(match), 1)
self.assertEqual(match[0].total_price_in_cents, 3700)
def test_non_winner_checkout_blocked(self):
auction_id = self._ended_auction_with_winner()
# Logged in as user1 (the seller). Should be blocked.
res = self.testapp.post(
f"/a/{auction_id}/checkout", expect_errors=True,
)
self.assertIn(res.status_int, (302, 303))
def test_active_auction_checkout_blocked(self):
from ..models.auction import (
MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Live", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1
self.dbsession.add(product)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 1_000
auction.end_timestamp = now_timestamp() + 3_600_000
self.dbsession.add(auction)
self.dbsession.flush()
auction_id = auction.uuid_str
transaction.commit()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/a/{auction_id}/checkout", status=302,
)
# Redirect back to /a/{id}, not /cart.
self.assertIn("/a/", res.location)
self.assertNotIn("/cart", res.location)
class TestOfferCheckout(_AuthenticatedBase):
"""MPS-21: /o/{id}/checkout creates a cart for the buyer with the
offer's accepted amount as total."""
def _accepted_offer(self):
"""user2 (buyer) has an offer accepted by user1 (seller)."""
from ..models.offer import (
MpsOffer, MpsOfferEvent, OFFER_STATE_ACCEPTED,
OFFER_EVENT_ACCEPT, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="For sale", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
import json
product.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"product": "pdf"}},
)
self.dbsession.add(product)
self.dbsession.flush()
from ..models.user import get_or_create_user_by_email
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=7500,
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
self.dbsession.add(offer)
self.dbsession.flush()
offer_id = offer.uuid_str
transaction.commit()
return offer_id
def test_buyer_checkout_creates_cart_with_override(self):
offer_id = self._accepted_offer()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
self.testapp.post(f"/o/{offer_id}/checkout", status=302)
from ..models.cart import Cart
from ..models.cart_offer import MpsCartOffer
from ..models.user import get_or_create_user_by_email
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
carts_with_offer = (
self.dbsession.query(Cart)
.filter(Cart.user_id == buyer.id)
.all()
)
match = [c for c in carts_with_offer if c.cart_offers]
self.assertEqual(len(match), 1)
self.assertEqual(match[0].total_price_in_cents, 7500)
def test_non_buyer_checkout_blocked(self):
offer_id = self._accepted_offer()
# Logged in as user1 (seller). Should be blocked.
res = self.testapp.post(
f"/o/{offer_id}/checkout", status=302,
)
self.assertIn("/o/", res.location)
self.assertNotIn("/cart", res.location)
def test_pending_offer_checkout_blocked(self):
from ..models.offer import (
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Pending", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
from ..models.user import get_or_create_user_by_email
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=8000,
expires_timestamp=now_timestamp() + 86_400_000,
)
# state stays PENDING.
self.dbsession.add(offer)
self.dbsession.flush()
offer_id = offer.uuid_str
transaction.commit()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/o/{offer_id}/checkout", status=302,
)
# Redirect back to offer page, not /cart.
self.assertIn("/o/", res.location)
self.assertNotIn("/cart", res.location)

View file

@ -4704,3 +4704,273 @@ class TestOfferLibOrchestrators(DatabaseIntegrationTests):
mark_paid(offer)
self.assertEqual(offer.state, OFFER_STATE_PAID)
self.assertIsNotNone(offer.paid_timestamp)
class TestCartAuctionAssociation(DatabaseIntegrationTests):
"""MPS-20: mps_cart_auction association — links a cart to a winning
auction so checkout pays the bid amount, not list price."""
def _setup(self):
from ..models.auction import (
MpsAuction, AUCTION_STATE_ENDED, now_timestamp,
)
from ..models.cart import Cart
from ..models.product import Product
shop = Shop(
name="Auction Cart 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 = "ac.test"
self.dbsession.add(shop)
product = Product(title="Won item", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1
self.dbsession.add(product)
winner = get_or_create_user_by_email(self.dbsession, "winner@ac.test")
self.dbsession.add(winner)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ENDED
auction.start_timestamp = now_timestamp() - 7_200_000
auction.end_timestamp = now_timestamp() - 60_000
auction.winner = winner
self.dbsession.add(auction)
self.dbsession.flush()
cart = Cart(user=winner)
cart.shop = shop
self.dbsession.add(cart)
self.dbsession.flush()
return shop, product, auction, winner, cart
def test_create_cart_auction_association(self):
from ..models.cart_auction import MpsCartAuction
shop, product, auction, winner, cart = self._setup()
ca = MpsCartAuction(cart=cart, auction=auction)
self.dbsession.add(ca)
self.dbsession.flush()
cart_id = cart.id
auction_id = auction.id
transaction.commit()
# Re-query and verify backref.
from ..models.cart import Cart
fetched_cart = self.dbsession.query(Cart).get(cart_id)
self.assertEqual(len(fetched_cart.cart_auctions), 1)
self.assertEqual(fetched_cart.cart_auctions[0].auction.id, auction_id)
def test_cart_auction_cascade_on_cart_delete(self):
from ..models.cart_auction import MpsCartAuction
shop, product, auction, winner, cart = self._setup()
ca = MpsCartAuction(cart=cart, auction=auction)
self.dbsession.add(ca)
self.dbsession.flush()
transaction.commit()
self.assertEqual(self.dbsession.query(MpsCartAuction).count(), 1)
self.dbsession.delete(cart)
self.dbsession.flush()
self.assertEqual(self.dbsession.query(MpsCartAuction).count(), 0)
# Auction itself unaffected.
from ..models.auction import MpsAuction
self.assertEqual(self.dbsession.query(MpsAuction).count(), 1)
transaction.commit()
class TestCartOfferAssociation(DatabaseIntegrationTests):
"""MPS-21: mps_cart_offer association — links a cart to an accepted
offer so checkout pays the agreed amount."""
def _setup(self):
from ..models.cart import Cart
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
)
from ..models.product import Product
shop = Shop(
name="Offer Cart 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 = "oc.test"
self.dbsession.add(shop)
product = Product(title="Offer-bought", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
buyer = get_or_create_user_by_email(self.dbsession, "buyer@oc.test")
self.dbsession.add(buyer)
self.dbsession.flush()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=8000,
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
self.dbsession.add(offer)
self.dbsession.flush()
cart = Cart(user=buyer)
cart.shop = shop
self.dbsession.add(cart)
self.dbsession.flush()
return shop, product, offer, buyer, cart
def test_create_cart_offer_association(self):
from ..models.cart_offer import MpsCartOffer
shop, product, offer, buyer, cart = self._setup()
co = MpsCartOffer(cart=cart, offer=offer)
self.dbsession.add(co)
self.dbsession.flush()
cart_id = cart.id
offer_id = offer.id
transaction.commit()
from ..models.cart import Cart
fetched_cart = self.dbsession.query(Cart).get(cart_id)
self.assertEqual(len(fetched_cart.cart_offers), 1)
self.assertEqual(fetched_cart.cart_offers[0].offer.id, offer_id)
def test_cart_offer_cascade_on_cart_delete(self):
from ..models.cart_offer import MpsCartOffer
shop, product, offer, buyer, cart = self._setup()
co = MpsCartOffer(cart=cart, offer=offer)
self.dbsession.add(co)
self.dbsession.flush()
transaction.commit()
self.assertEqual(self.dbsession.query(MpsCartOffer).count(), 1)
self.dbsession.delete(cart)
self.dbsession.flush()
self.assertEqual(self.dbsession.query(MpsCartOffer).count(), 0)
# Offer itself unaffected.
from ..models.offer import MpsOffer
self.assertEqual(self.dbsession.query(MpsOffer).count(), 1)
transaction.commit()
class TestCartTotalOverride(DatabaseIntegrationTests):
"""MPS-20 + MPS-21: cart total uses auction.winning_bid or
offer.current_amount when association is present."""
def _shop_product(self, list_price=10000):
from ..models.product import Product
shop = Shop(
name="Total Override 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 = "to.test"
self.dbsession.add(shop)
product = Product(title="Item", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
self.dbsession.add(product)
self.dbsession.flush()
return shop, product
def test_no_override_when_no_association(self):
from ..models.cart import Cart
shop, product = self._shop_product(list_price=10000)
user = get_or_create_user_by_email(self.dbsession, "u@to.test")
self.dbsession.add(user)
cart = Cart(user=user)
cart.shop = shop
self.dbsession.add(cart)
cart.add_product(product)
self.dbsession.flush()
self.assertIsNone(cart.auction_offer_override_in_cents)
self.assertEqual(cart.total_price_in_cents, 10000)
def test_auction_override_uses_winning_bid(self):
from ..models.cart import Cart
from ..models.cart_auction import MpsCartAuction
from ..models.auction import (
MpsAuction, MpsBid, AUCTION_STATE_ENDED, now_timestamp,
)
shop, product = self._shop_product(list_price=10000)
product.pricing_mode = 1
winner = get_or_create_user_by_email(self.dbsession, "w@to.test")
self.dbsession.add(winner)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ENDED
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1_000
auction.winner = winner
self.dbsession.add(auction)
bid = MpsBid(auction=auction, bidder=winner, amount_in_cents=4200)
bid.is_winning = True
self.dbsession.add(bid)
self.dbsession.flush()
cart = Cart(user=winner)
cart.shop = shop
cart.add_product(product)
self.dbsession.add(cart)
self.dbsession.add(MpsCartAuction(cart=cart, auction=auction))
self.dbsession.flush()
# Override: $42 not $100.
self.assertEqual(cart.auction_offer_override_in_cents, 4200)
self.assertEqual(cart.total_price_in_cents, 4200)
def test_offer_override_uses_current_amount(self):
from ..models.cart import Cart
from ..models.cart_offer import MpsCartOffer
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
)
shop, product = self._shop_product(list_price=10000)
product.pricing_mode = 3
buyer = get_or_create_user_by_email(self.dbsession, "b@to.test")
self.dbsession.add(buyer)
self.dbsession.flush()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=8000,
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
self.dbsession.add(offer)
self.dbsession.flush()
cart = Cart(user=buyer)
cart.shop = shop
cart.add_product(product)
self.dbsession.add(cart)
self.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
self.dbsession.flush()
# Override: $80 not $100.
self.assertEqual(cart.auction_offer_override_in_cents, 8000)
self.assertEqual(cart.total_price_in_cents, 8000)

View file

@ -227,3 +227,46 @@ def auction_watch(request):
request.dbsession.add(watcher)
request.dbsession.flush()
return {"watching": True}
@view_config(route_name="auction_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def auction_checkout(request):
"""Winner checkout: build a cart linked to the auction, redirect to /cart.
Cart total is overridden to auction.winning_bid amount via the
cart_auction association (Cart.auction_offer_override_in_cents).
"""
from ..models.cart import Cart
from ..models.cart_auction import MpsCartAuction
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
if auction.is_active or auction.is_draft or auction.is_scheduled:
request.session.flash(("Auction is not yet ended.", "error"))
return HTTPFound(f"/a/{auction.uuid_str}")
if auction.is_settled:
request.session.flash(("Auction has already been paid.", "error"))
return HTTPFound(f"/a/{auction.uuid_str}")
if auction.is_cancelled:
request.session.flash(("Auction was cancelled.", "error"))
return HTTPFound(f"/a/{auction.uuid_str}")
if auction.winner is None or auction.winner != request.user:
request.session.flash(("Only the auction winner can check out.", "error"))
return HTTPFound(f"/a/{auction.uuid_str}")
# Build a fresh cart for this auction. Single product, override price.
cart = Cart(user=request.user)
cart.shop = auction.shop
cart.active = True
request.dbsession.add(cart)
request.dbsession.flush()
cart.add_product(auction.product)
request.dbsession.add(
MpsCartAuction(cart=cart, auction=auction)
)
request.dbsession.flush()
return HTTPFound("/cart")

View file

@ -95,6 +95,32 @@ def _create_gift_card_transactions(cart, invoices, request):
return created_gift_cards
def _finalize_auction_offer_state(cart, request):
"""MPS-20 + MPS-21: when a cart linked to an auction or offer is paid,
flip auction.state=SETTLED and offer.state=PAID. Called from the
cart success path after invoices are written."""
from ..models.auction import (
AUCTION_STATE_SETTLED,
now_timestamp as auction_now,
)
from ..lib.offer import mark_paid as offer_mark_paid
for ca in list(cart.cart_auctions):
auction = ca.auction
winning_bid = (
auction.bids.filter_by(is_winning=True).one_or_none()
)
auction.state = AUCTION_STATE_SETTLED
if winning_bid is not None:
auction.winner = winning_bid.bidder
auction.winning_bid_id = winning_bid.id
auction.updated_timestamp = auction_now()
request.dbsession.add(auction)
for co in list(cart.cart_offers):
offer_mark_paid(co.offer)
def get_cart_from_matchdict(request):
"""
This function uses the cart_id from the url path
@ -817,6 +843,7 @@ def cart_complete_checkout(request):
cart.update_inventory(request.shop_location)
_create_gift_card_transactions(cart, invoices, request)
_finalize_auction_offer_state(cart, request)
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)

View file

@ -228,3 +228,35 @@ def offer_decline(request):
@user_required(flash_msg="Please log in to act on this offer.")
def offer_withdraw(request):
return _offer_action(request, withdraw_offer)
@view_config(route_name="offer_checkout", request_method="POST")
@user_required(flash_msg="Please log in to pay.")
def offer_checkout(request):
"""Buyer checkout for an accepted offer: build a cart linked to the
offer, redirect to /cart. Cart total is overridden to the agreed
amount via the cart_offer association.
"""
from ..models.cart import Cart
from ..models.cart_offer import MpsCartOffer
from ..models.offer import OFFER_STATE_ACCEPTED
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if offer is None:
raise HTTPNotFound()
if offer.state != OFFER_STATE_ACCEPTED:
request.session.flash(("Only accepted offers can be paid.", "error"))
return HTTPFound(f"/o/{offer.uuid_str}")
if request.user != offer.buyer:
request.session.flash(("Only the buyer can check out.", "error"))
return HTTPFound(f"/o/{offer.uuid_str}")
cart = Cart(user=request.user)
cart.shop = offer.shop
cart.active = True
request.dbsession.add(cart)
request.dbsession.flush()
cart.add_product(offer.product)
request.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
request.dbsession.flush()
return HTTPFound("/cart")