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:
parent
17ce2ae173
commit
3c9fc5544b
12 changed files with 793 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
40
make_post_sell/models/cart_auction.py
Normal file
40
make_post_sell/models/cart_auction.py
Normal 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()
|
||||
38
make_post_sell/models/cart_offer.py
Normal file
38
make_post_sell/models/cart_offer.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue