Skip to content

Commit 981ed90

Browse files
committed
refactor bidding logic
1 parent 380ead4 commit 981ed90

File tree

7 files changed

+100
-51
lines changed

7 files changed

+100
-51
lines changed

src/modules/bidding/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ System will let you know (by email) if someone outbids you, and you can decide i
1010
Sometimes you can be automatically outbid (if some other buyer sets his maximum limit higher that yours).
1111

1212
For example:
13-
1. Alice wants to sell X. She sets the initial price for X item as 10 USD.
14-
2. Bob wants to place a bid. The minimum amount is 10 USD (the ask price), and he places the bid of 15 USD. As the only bidder, he is the winner and the current price for X is 10 USD.
13+
1. Alice wants to sell X. She sets the ask price for this item as 10 USD.
14+
2. Bob wants to place a bid on X. The minimum amount is 10 USD (the ask price), and he places the bid of 15 USD. As the only bidder, he is the winner and the current price for X is 10 USD.
1515
3. Alice and Bob are notified by email.
1616
4. Charlie places his bid, but now the minimum price he can bid is 11 USD, so he decides to bid 12 USD. As this is not enough to outbid Bob, he is not the winner, and the price is increased to 12 USD.
1717
5. Charlie places another bid, this time with the amount of 20 USD. As this is more than Bob's maximum limit, Charlie is the winner and the price is increased to 16 USD.

src/modules/bidding/application/event.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def when_listing_is_published_start_auction(
1515
listing = Listing(
1616
id=event.listing_id,
1717
seller=Seller(id=event.seller_id),
18-
initial_price=event.ask_price,
18+
ask_price=event.ask_price,
1919
starts_at=datetime.now(),
2020
ends_at=datetime.now() + timedelta(days=7),
2121
)

src/modules/bidding/domain/entities.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from modules.bidding.domain.rules import (
66
BidCanBeRetracted,
77
ListingCanBeCancelled,
8-
PlacedBidMustBeGreaterThanCurrentWinningBid,
8+
PlacedBidMustBeGreaterOrEqualThanNextMinimumBid,
99
)
1010
from modules.bidding.domain.value_objects import Bid, Bidder, Seller
1111
from seedwork.domain.entities import AggregateRoot
@@ -41,35 +41,30 @@ class ListingCancelledEvent(DomainEvent):
4141
@dataclass(kw_only=True)
4242
class Listing(AggregateRoot):
4343
seller: Seller
44-
initial_price: Money
44+
ask_price: Money
4545
starts_at: datetime
4646
ends_at: datetime
4747
bids: list[Bid] = field(default_factory=list)
4848

4949
@property
5050
def current_price(self) -> Money:
51-
highest_price = self.initial_price
52-
second_highest_price = self.initial_price
51+
"""The current price is the price buyers are competing against"""
52+
if len(self.bids) < 2:
53+
return self.ask_price
5354

54-
if len(self.bids) == 0:
55-
return self.initial_price
55+
sorted_prices = sorted([bid.max_price for bid in self.bids], reverse=True)
56+
return sorted_prices[1]
5657

57-
if len(self.bids) == 1:
58-
return min(self.bids[0].max_price, self.initial_price)
59-
60-
for bid in self.bids:
61-
if bid.max_price > highest_price:
62-
second_highest_price = highest_price
63-
highest_price = bid.max_price
64-
65-
return second_highest_price + Money(1, currency=self.initial_price.currency)
58+
@property
59+
def next_minimum_price(self) -> Money:
60+
return self.current_price + Money(1, currency=self.ask_price.currency)
6661

6762
# public commands
6863
def place_bid(self, bid: Bid) -> type[DomainEvent]:
6964
"""Public method"""
7065
self.check_rule(
71-
PlacedBidMustBeGreaterThanCurrentWinningBid(
72-
bid=bid, current_price=self.current_price
66+
PlacedBidMustBeGreaterOrEqualThanNextMinimumBid(
67+
current_price=bid.max_price, next_minimum_price=self.next_minimum_price
7368
)
7469
)
7570

src/modules/bidding/domain/rules.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,21 @@
22

33
from pydantic import Field
44

5-
from modules.bidding.domain.value_objects import Bid
65
from seedwork.domain.rules import BusinessRule
76
from seedwork.domain.value_objects import Money
87

98

10-
class PlacedBidMustBeGreaterThanCurrentWinningBid(BusinessRule):
11-
__message = "Placed bid must be greater than {current_price}"
9+
class PlacedBidMustBeGreaterOrEqualThanNextMinimumBid(BusinessRule):
10+
__message = "Placed bid must be greater or equal than {next_minimum_price}"
1211

13-
bid: Bid
1412
current_price: Money
13+
next_minimum_price: Money
1514

1615
def is_broken(self) -> bool:
17-
return self.bid.max_price <= self.current_price
16+
return self.current_price < self.next_minimum_price
1817

1918
def get_message(self) -> str:
20-
return self.__message.format(current_price=self.current_price)
19+
return self.__message.format(next_minimum_price=self.next_minimum_price)
2120

2221

2322
class BidCanBeRetracted(BusinessRule):

src/modules/bidding/infrastructure/listing_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def model_to_entity(self, instance: ListingModel) -> Listing:
7777
return Listing(
7878
id=deserialize_id(instance.id),
7979
seller=Seller(id=deserialize_id(d["seller_id"])),
80-
initial_price=deserialize_money(d["initial_price"]),
80+
ask_price=deserialize_money(d["ask_price"]),
8181
starts_at=deserialize_datetime(d["starts_at"]),
8282
ends_at=deserialize_datetime(d["ends_at"]),
8383
)
@@ -88,7 +88,7 @@ def entity_to_model(self, entity: Listing) -> ListingModel:
8888
data={
8989
"starts_at": serialize_datetime(entity.starts_at),
9090
"ends_at": serialize_datetime(entity.ends_at),
91-
"initial_price": serialize_money(entity.initial_price),
91+
"ask_price": serialize_money(entity.ask_price),
9292
"seller_id": serialize_id(entity.seller.id),
9393
"bids": [serialize_bid(b) for b in entity.bids],
9494
},

src/modules/bidding/tests/domain/test_bidding.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_listing_initial_price():
1414
listing = Listing(
1515
id=Listing.next_id(),
1616
seller=seller,
17-
initial_price=Money(10),
17+
ask_price=Money(10),
1818
starts_at=datetime.utcnow(),
1919
ends_at=datetime.utcnow(),
2020
)
@@ -30,7 +30,7 @@ def test_place_one_bid():
3030
listing = Listing(
3131
id=Listing.next_id(),
3232
seller=seller,
33-
initial_price=Money(10),
33+
ask_price=Money(10),
3434
starts_at=datetime.utcnow(),
3535
ends_at=datetime.utcnow(),
3636
)
@@ -40,22 +40,77 @@ def test_place_one_bid():
4040

4141

4242
@pytest.mark.unit
43-
def test_place_two_bids():
43+
def test_place_two_bids_second_buyer_outbids():
4444
now = datetime.utcnow()
45-
seller = Seller(id=UUID.v4())
46-
bidder1 = Bidder(id=UUID.v4())
47-
bidder2 = Bidder(id=UUID.v4())
45+
seller = Seller(id=UUID(int=1))
46+
bidder1 = Bidder(id=UUID(int=2))
47+
bidder2 = Bidder(id=UUID(int=3))
4848
listing = Listing(
49-
id=Listing.next_id(),
49+
id=UUID(int=4),
5050
seller=seller,
51-
initial_price=Money(10),
51+
ask_price=Money(10),
5252
starts_at=datetime.utcnow(),
5353
ends_at=datetime.utcnow(),
5454
)
55-
listing.place_bid(Bid(max_price=Money(15), bidder=bidder1, placed_at=now))
56-
listing.place_bid(Bid(max_price=Money(30), bidder=bidder2, placed_at=now))
55+
assert listing.current_price == Money(10)
56+
assert listing.next_minimum_price == Money(11)
57+
58+
# bidder1 places a bid
59+
listing.place_bid(Bid(bidder=bidder1, max_price=Money(15), placed_at=now))
60+
assert listing.current_price == Money(10)
61+
assert listing.next_minimum_price == Money(11)
62+
63+
# bidder2 successfully outbids bidder1
64+
listing.place_bid(Bid(bidder=bidder2, max_price=Money(30), placed_at=now))
65+
assert listing.current_price == Money(15)
66+
assert listing.next_minimum_price == Money(16)
5767
assert listing.winning_bid == Bid(Money(30), bidder=bidder2, placed_at=now)
58-
assert listing.current_price == Money(16)
68+
69+
70+
@pytest.mark.unit
71+
def test_place_two_bids_second_buyer_fails_to_outbid():
72+
now = datetime.utcnow()
73+
seller = Seller(id=UUID(int=1))
74+
bidder1 = Bidder(id=UUID(int=2))
75+
bidder2 = Bidder(id=UUID(int=3))
76+
listing = Listing(
77+
id=UUID(int=4),
78+
seller=seller,
79+
ask_price=Money(10),
80+
starts_at=datetime.utcnow(),
81+
ends_at=datetime.utcnow(),
82+
)
83+
84+
# bidder1 places a bid
85+
listing.place_bid(Bid(bidder=bidder1, max_price=Money(30), placed_at=now))
86+
assert listing.current_price == Money(10)
87+
assert listing.next_minimum_price == Money(11)
88+
89+
# bidder2 tries to outbid bidder1...
90+
listing.place_bid(Bid(bidder=bidder2, max_price=Money(20), placed_at=now))
91+
92+
# ...but he fails. bidder1 is still a winner, but current price changes
93+
assert listing.winning_bid == Bid(Money(30), bidder=bidder1, placed_at=now)
94+
assert listing.current_price == Money(20)
95+
96+
97+
@pytest.mark.unit
98+
def test_place_two_bids_second_buyer_fails_to_outbid_with_same_amount():
99+
now = datetime.utcnow()
100+
seller = Seller(id=UUID(int=1))
101+
bidder1 = Bidder(id=UUID(int=2))
102+
bidder2 = Bidder(id=UUID(int=3))
103+
listing = Listing(
104+
id=UUID(int=4),
105+
seller=seller,
106+
ask_price=Money(10),
107+
starts_at=datetime.utcnow(),
108+
ends_at=datetime.utcnow(),
109+
)
110+
listing.place_bid(Bid(bidder=bidder1, max_price=Money(30), placed_at=now))
111+
listing.place_bid(Bid(bidder=bidder2, max_price=Money(30), placed_at=now))
112+
assert listing.winning_bid == Bid(Money(30), bidder=bidder1, placed_at=now)
113+
assert listing.current_price == Money(30)
59114

60115

61116
@pytest.mark.unit
@@ -66,7 +121,7 @@ def test_place_two_bids_by_same_bidder():
66121
listing = Listing(
67122
id=Listing.next_id(),
68123
seller=seller,
69-
initial_price=Money(10),
124+
ask_price=Money(10),
70125
starts_at=datetime.utcnow(),
71126
ends_at=datetime.utcnow(),
72127
)
@@ -85,7 +140,7 @@ def test_cannot_place_bid_if_listing_ended():
85140
listing = Listing(
86141
id=Listing.next_id(),
87142
seller=seller,
88-
initial_price=Money(10),
143+
ask_price=Money(10),
89144
starts_at=datetime.utcnow(),
90145
ends_at=datetime.utcnow(),
91146
)
@@ -96,7 +151,7 @@ def test_cannot_place_bid_if_listing_ended():
96151
)
97152
with pytest.raises(
98153
BusinessRuleValidationException,
99-
match="PlacedBidMustBeGreaterThanCurrentWinningBid",
154+
match="PlacedBidMustBeGreaterOrEqualThanNextMinimumBid",
100155
):
101156
listing.place_bid(bid)
102157

@@ -108,7 +163,7 @@ def test_retract_bid():
108163
listing = Listing(
109164
id=Listing.next_id(),
110165
seller=seller,
111-
initial_price=Money(10),
166+
ask_price=Money(10),
112167
starts_at=datetime.utcnow(),
113168
ends_at=datetime.utcnow(),
114169
)
@@ -129,7 +184,7 @@ def test_cancel_listing():
129184
listing = Listing(
130185
id=Listing.next_id(),
131186
seller=seller,
132-
initial_price=Money(10),
187+
ask_price=Money(10),
133188
starts_at=now,
134189
ends_at=now + timedelta(days=10),
135190
)
@@ -147,7 +202,7 @@ def test_can_cancel_listing_with_bids():
147202
listing = Listing(
148203
id=Listing.next_id(),
149204
seller=seller,
150-
initial_price=Money(10),
205+
ask_price=Money(10),
151206
starts_at=now,
152207
ends_at=now + timedelta(days=10),
153208
)
@@ -171,7 +226,7 @@ def test_cannot_cancel_listing_with_bids():
171226
listing = Listing(
172227
id=Listing.next_id(),
173228
seller=seller,
174-
initial_price=Money(10),
229+
ask_price=Money(10),
175230
starts_at=now,
176231
ends_at=now + timedelta(hours=1),
177232
)

src/modules/bidding/tests/infrastructure/test_listing_repository.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_listing_data_mapper_maps_entity_to_model():
2323
listing = Listing(
2424
id=UUID(int=1),
2525
seller=Seller(id=UUID(int=2)),
26-
initial_price=Money(100, "PLN"),
26+
ask_price=Money(100, "PLN"),
2727
starts_at=datetime.datetime(2020, 12, 1),
2828
ends_at=datetime.datetime(2020, 12, 31),
2929
bids=[
@@ -42,7 +42,7 @@ def test_listing_data_mapper_maps_entity_to_model():
4242
id=UUID(int=1),
4343
data={
4444
"seller_id": "00000000-0000-0000-0000-000000000002",
45-
"initial_price": {
45+
"ask_price": {
4646
"amount": 100,
4747
"currency": "PLN",
4848
},
@@ -70,7 +70,7 @@ def test_listing_data_mapper_maps_model_to_entity():
7070
id=UUID(int=1),
7171
data={
7272
"seller_id": "00000000-0000-0000-0000-000000000002",
73-
"initial_price": {
73+
"ask_price": {
7474
"amount": 100,
7575
"currency": "PLN",
7676
},
@@ -85,7 +85,7 @@ def test_listing_data_mapper_maps_model_to_entity():
8585
expected = Listing(
8686
id=UUID(int=1),
8787
seller=Seller(id=UUID("00000000000000000000000000000002")),
88-
initial_price=Money(100, "PLN"),
88+
ask_price=Money(100, "PLN"),
8989
starts_at=datetime.datetime(2020, 12, 1),
9090
ends_at=datetime.datetime(2020, 12, 31),
9191
)
@@ -97,7 +97,7 @@ def test_listing_persistence(db_session):
9797
original = Listing(
9898
id=Listing.next_id(),
9999
seller=Seller(id=uuid.uuid4()),
100-
initial_price=Money(100, "PLN"),
100+
ask_price=Money(100, "PLN"),
101101
starts_at=datetime.datetime(2020, 12, 1),
102102
ends_at=datetime.datetime(2020, 12, 31),
103103
)

0 commit comments

Comments
 (0)