Skip to content

Commit e32d98c

Browse files
committed
Add finished auction payment workflow
1 parent fa1b99d commit e32d98c

8 files changed

Lines changed: 207 additions & 31 deletions

File tree

client/src/main/java/com/auction/controller/AuctionListController.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public class AuctionListController {
5151
@FXML private Button cancelBtn;
5252
@FXML private Button deleteBtn;
5353
@FXML private Button placeBidBtn;
54+
@FXML private Button paySellerBtn;
5455
@FXML private Label statusLabel;
5556

5657
private AppState appState;
@@ -87,6 +88,7 @@ private void setupTable() {
8788
});
8889
return row;
8990
});
91+
itemTable.getSelectionModel().selectedItemProperty().addListener((obs, oldItem, newItem) -> updatePaySellerButton());
9092
colType.setCellValueFactory(c -> new SimpleStringProperty(typeName(c.getValue())));
9193
colStatus.setCellValueFactory(new PropertyValueFactory<>("status"));
9294
colPrice.setCellValueFactory(c ->
@@ -119,6 +121,7 @@ private void configureRoleButtons() {
119121
} else if (appState.currentUser instanceof Bidder) {
120122
show(addFundsBtn, withdrawBtn, placeBidBtn);
121123
}
124+
updatePaySellerButton();
122125
}
123126

124127
private void show(javafx.scene.Node... nodes) {
@@ -161,7 +164,7 @@ private void onAddFunds() {
161164
@FXML
162165
private void onWithdraw() {
163166
double committed = appState.itemRepository.findAll().stream()
164-
.filter(i -> i.getStatus() == AuctionStatus.RUNNING
167+
.filter(i -> (i.getStatus() == AuctionStatus.RUNNING || i.getStatus() == AuctionStatus.FINISHED)
165168
&& appState.currentUser.getId().equals(i.getCurrentWinnerId()))
166169
.mapToDouble(Item::getCurrentPrice)
167170
.sum();
@@ -236,6 +239,28 @@ private void onPlaceBid() {
236239
switchToBidding(selected);
237240
}
238241

242+
@FXML
243+
private void onPaySeller() {
244+
Item selected = itemTable.getSelectionModel().getSelectedItem();
245+
if (selected == null) { showStatus("Select an item first.", true); return; }
246+
if (selected.getStatus() != AuctionStatus.FINISHED) {
247+
showStatus("Auction must be FINISHED before payment.", true);
248+
return;
249+
}
250+
if (!appState.currentUser.getId().equals(selected.getCurrentWinnerId())) {
251+
showStatus("Only the winning bidder can pay the seller.", true);
252+
return;
253+
}
254+
try {
255+
appState.auctionService.paySeller(selected.getId(), appState.currentUser);
256+
refreshCurrentUser();
257+
refreshTable();
258+
showStatus("Seller paid.", false);
259+
} catch (Exception e) {
260+
showStatus(e.getMessage(), true);
261+
}
262+
}
263+
239264
private void switchToBidding(Item item) {
240265
try {
241266
FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/bidding.fxml"));
@@ -261,6 +286,17 @@ private void refreshTable() {
261286
itemTable.setItems(FXCollections.observableArrayList(appState.itemRepository.findAll()));
262287
itemTable.refresh();
263288
refreshUserInfo();
289+
updatePaySellerButton();
290+
}
291+
292+
private void updatePaySellerButton() {
293+
if (paySellerBtn == null || appState == null || appState.currentUser == null) return;
294+
Item selected = itemTable.getSelectionModel().getSelectedItem();
295+
boolean canPay = selected != null
296+
&& selected.getStatus() == AuctionStatus.FINISHED
297+
&& appState.currentUser.getId().equals(selected.getCurrentWinnerId());
298+
paySellerBtn.setVisible(canPay);
299+
paySellerBtn.setManaged(canPay);
264300
}
265301

266302
private void registerRealtimeUpdates() {

client/src/main/java/com/auction/service/rest/RestAuctionService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public void cancelAuction(String itemId, User requestingUser) {
4242
post("/auction/" + itemId + "/cancel");
4343
}
4444

45+
@Override
46+
public void paySeller(String itemId, User requestingUser) {
47+
post("/auction/" + itemId + "/pay");
48+
}
49+
4550
@Override
4651
public void recoverScheduledAuctions() {
4752
// Server handles this on its own startup.

client/src/main/resources/fxml/auction-list.fxml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
<!-- Bidder / Seller -->
5050
<Button fx:id="placeBidBtn" text="Place Bid" onAction="#onPlaceBid" styleClass="btn-primary" managed="false" visible="false"/>
51+
<Button fx:id="paySellerBtn" text="Pay Seller" onAction="#onPaySeller" styleClass="btn-primary" managed="false" visible="false"/>
5152
</HBox>
5253

5354
<Label fx:id="statusLabel" styleClass="error-label"/>

server/src/main/java/com/auction/server/controller/AuctionController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ public void handleCancelAuction(Context ctx) {
5050
ctx.json(Map.of("message", "Auction canceled."));
5151
}
5252

53+
public void handlePaySeller(Context ctx) {
54+
String itemId = ctx.pathParam("id");
55+
User user = getAuthenticatedUser(ctx);
56+
auctionService.paySeller(itemId, user);
57+
broadcastItemUpdated(itemId);
58+
ctx.json(Map.of("message", "Seller paid."));
59+
}
60+
5361
private User getAuthenticatedUser(Context ctx) {
5462
String userId = ctx.attribute("userId");
5563
return userRepo.findById(userId)

server/src/main/java/com/auction/server/controller/UserController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ private double committedAmount(String userId) {
144144
if (itemRepo == null) return 0.0;
145145
Map<String, Double> commitmentsByItem = new HashMap<>();
146146
for (Item item : itemRepo.findAll()) {
147-
if (item.getStatus() == AuctionStatus.RUNNING && userId.equals(item.getCurrentWinnerId())) {
147+
if ((item.getStatus() == AuctionStatus.RUNNING || item.getStatus() == AuctionStatus.FINISHED)
148+
&& userId.equals(item.getCurrentWinnerId())) {
148149
commitmentsByItem.merge(item.getId(), item.getCurrentPrice(), Math::max);
149150
}
150151
}

server/src/main/java/com/auction/server/network/AuctionServer.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ private void registerRoutes(UserController user, ItemController item,
7878
app.post("/api/auction/{id}/start", auction::handleStartAuction);
7979
app.post("/api/auction/{id}/end", auction::handleEndAuction);
8080
app.post("/api/auction/{id}/cancel", auction::handleCancelAuction);
81+
app.post("/api/auction/{id}/pay", auction::handlePaySeller);
8182
}
8283

8384
private ObjectMapper createObjectMapper() {

shared/src/main/java/com/auction/service/AuctionService.java

Lines changed: 113 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.auction.exception.UnauthorizedActionException;
55
import com.auction.model.Admin;
66
import com.auction.model.AuctionStatus;
7+
import com.auction.model.BannableUser;
78
import com.auction.model.Bidder;
89
import com.auction.model.Item;
910
import com.auction.model.Seller;
@@ -22,6 +23,9 @@
2223

2324
public class AuctionService {
2425

26+
private static final Duration PAYMENT_WINDOW = Duration.ofHours(8);
27+
private static final long NON_PAYMENT_BAN_SECONDS = Duration.ofDays(7).toSeconds();
28+
2529
private final ItemRepository itemRepository;
2630
private final UserRepository userRepository;
2731
private final TransactionRunner txRunner;
@@ -53,21 +57,20 @@ private void runInTx(Runnable action) {
5357
}
5458

5559
/**
56-
* Recover from server restarts: any auction left in RUNNING status whose
57-
* end-time has already passed is closed immediately; any still-active
58-
* RUNNING auction has its close timer rescheduled.
60+
* Recover from server restarts: RUNNING auctions are closed or rescheduled,
61+
* and FINISHED auctions are expired or rescheduled for their payment window.
5962
*/
6063
public void recoverScheduledAuctions() {
6164
LocalDateTime now = LocalDateTime.now();
6265
for (Item item : itemRepository.findAll()) {
63-
if (item.getStatus() != AuctionStatus.RUNNING) continue;
64-
if (item.getBidEndTime() == null || !now.isBefore(item.getBidEndTime())) {
65-
// Already past the end time -> close now.
66-
closeAuction(item.getId());
67-
} else {
68-
long delayMs = Duration.between(now, item.getBidEndTime()).toMillis();
69-
String itemId = item.getId();
70-
scheduler.schedule(() -> closeAuction(itemId), delayMs, TimeUnit.MILLISECONDS);
66+
if (item.getStatus() == AuctionStatus.RUNNING) {
67+
if (item.getBidEndTime() == null || !now.isBefore(item.getBidEndTime())) {
68+
closeAuction(item.getId());
69+
} else {
70+
scheduleClose(item.getId(), Duration.between(now, item.getBidEndTime()));
71+
}
72+
} else if (item.getStatus() == AuctionStatus.FINISHED) {
73+
scheduleOrExpireFinishedAuction(item);
7174
}
7275
}
7376
}
@@ -100,18 +103,16 @@ public void startAuction(String itemId, User requestingUser) {
100103
if (delayMs <= 0) {
101104
closeAuction(itemId);
102105
} else {
103-
scheduler.schedule(() -> closeAuction(itemId), delayMs, TimeUnit.MILLISECONDS);
106+
scheduleClose(itemId, Duration.ofMillis(delayMs));
104107
}
105108
} finally {
106109
lock.unlock();
107110
}
108111
}
109112

110113
/**
111-
* Settle the auction: if there's a winner with sufficient funds, debit
112-
* the winner, credit the seller, and mark FINISHED. If there are no
113-
* bids or the winner can't afford the price, mark CANCELED. Either way
114-
* the work commits atomically with the status flip.
114+
* Close bidding. Auctions with a winner move to FINISHED and wait for the
115+
* winner to pay the seller; auctions without a winner are canceled.
115116
*/
116117
public void closeAuction(String itemId) {
117118
closeAuction(itemId, false);
@@ -129,9 +130,12 @@ private void closeAuction(String itemId, boolean force) {
129130
LocalDateTime now = LocalDateTime.now();
130131
if (!force && item.getBidEndTime() != null && now.isBefore(item.getBidEndTime())) {
131132
long delayMs = Duration.between(now, item.getBidEndTime()).toMillis();
132-
scheduler.schedule(() -> closeAuction(itemId), delayMs, TimeUnit.MILLISECONDS);
133+
scheduleClose(itemId, Duration.ofMillis(delayMs));
133134
return;
134135
}
136+
if (force || item.getBidEndTime() == null) {
137+
item.setBidEndTime(now);
138+
}
135139

136140
String winnerId = item.getCurrentWinnerId();
137141
if (winnerId == null) {
@@ -141,6 +145,41 @@ private void closeAuction(String itemId, boolean force) {
141145
return;
142146
}
143147

148+
userRepository.findById(winnerId)
149+
.orElseThrow(() -> new ProductNotFoundException("Winner not found: " + winnerId));
150+
userRepository.findById(item.getSellerId())
151+
.orElseThrow(() -> new ProductNotFoundException("Seller not found: " + item.getSellerId()));
152+
153+
item.setStatus(AuctionStatus.FINISHED);
154+
runInTx(() -> itemRepository.update(item));
155+
schedulePaymentExpiry(item.getId(), paymentDeadline(item));
156+
if (statusChangeCallback != null) statusChangeCallback.run();
157+
} finally {
158+
lock.unlock();
159+
}
160+
}
161+
162+
public void paySeller(String itemId, User requestingUser) {
163+
ReentrantLock lock = ItemLockManager.getLock(itemId);
164+
lock.lock();
165+
try {
166+
Item item = getItem(itemId);
167+
168+
if (item.getStatus() != AuctionStatus.FINISHED)
169+
throw new IllegalStateException("Auction must be FINISHED before payment.");
170+
171+
String winnerId = item.getCurrentWinnerId();
172+
if (winnerId == null)
173+
throw new IllegalStateException("Cannot pay seller because this auction has no winner.");
174+
if (!winnerId.equals(requestingUser.getId()))
175+
throw new UnauthorizedActionException("Only the winning bidder can pay the seller.");
176+
177+
LocalDateTime deadline = paymentDeadline(item);
178+
if (!LocalDateTime.now().isBefore(deadline)) {
179+
expireFinishedAuction(itemId);
180+
throw new IllegalStateException("Payment window has expired.");
181+
}
182+
144183
User winnerUser = userRepository.findById(winnerId)
145184
.orElseThrow(() -> new ProductNotFoundException("Winner not found: " + winnerId));
146185
User sellerUser = userRepository.findById(item.getSellerId())
@@ -154,16 +193,9 @@ private void closeAuction(String itemId, boolean force) {
154193
else throw new IllegalStateException("Winner account is invalid for payment: " + winnerId);
155194

156195
double price = item.getCurrentPrice();
157-
if (winnerBalance < price) {
158-
// Winner can't afford it (e.g. they withdrew before settlement).
159-
// Cancel the auction so the seller doesn't get a phantom payout.
160-
item.setStatus(AuctionStatus.CANCELED);
161-
runInTx(() -> itemRepository.update(item));
162-
if (statusChangeCallback != null) statusChangeCallback.run();
163-
return;
164-
}
196+
if (winnerBalance < price)
197+
throw new IllegalStateException("Insufficient balance to pay seller.");
165198

166-
item.setStatus(AuctionStatus.FINISHED);
167199
if (winnerUser instanceof Bidder b) b.deductFunds(price);
168200
else ((Seller) winnerUser).withdraw(price);
169201
seller.addFunds(price);
@@ -198,7 +230,7 @@ public void cancelAuction(String itemId, User requestingUser) {
198230
Item item = getItem(itemId);
199231
AuctionStatus current = item.getStatus();
200232

201-
if (current == AuctionStatus.FINISHED || current == AuctionStatus.PAID)
233+
if (current == AuctionStatus.PAID)
202234
throw new IllegalStateException("Cannot cancel a settled auction.");
203235
if (current == AuctionStatus.CANCELED)
204236
throw new IllegalStateException("Auction is already CANCELED.");
@@ -218,4 +250,58 @@ private Item getItem(String itemId) {
218250
return itemRepository.findById(itemId)
219251
.orElseThrow(() -> new ProductNotFoundException("Item not found: " + itemId));
220252
}
253+
254+
private void scheduleClose(String itemId, Duration delay) {
255+
scheduler.schedule(() -> closeAuction(itemId), Math.max(0, delay.toMillis()), TimeUnit.MILLISECONDS);
256+
}
257+
258+
private void scheduleOrExpireFinishedAuction(Item item) {
259+
LocalDateTime deadline = paymentDeadline(item);
260+
if (!LocalDateTime.now().isBefore(deadline)) {
261+
expireFinishedAuction(item.getId());
262+
} else {
263+
schedulePaymentExpiry(item.getId(), deadline);
264+
}
265+
}
266+
267+
private void schedulePaymentExpiry(String itemId, LocalDateTime deadline) {
268+
long delayMs = Math.max(0, Duration.between(LocalDateTime.now(), deadline).toMillis());
269+
scheduler.schedule(() -> expireFinishedAuction(itemId), delayMs, TimeUnit.MILLISECONDS);
270+
}
271+
272+
private LocalDateTime paymentDeadline(Item item) {
273+
LocalDateTime finishedAt = item.getBidEndTime() != null ? item.getBidEndTime() : LocalDateTime.now();
274+
return finishedAt.plus(PAYMENT_WINDOW);
275+
}
276+
277+
private void expireFinishedAuction(String itemId) {
278+
ReentrantLock lock = ItemLockManager.getLock(itemId);
279+
lock.lock();
280+
try {
281+
Item item = getItem(itemId);
282+
if (item.getStatus() != AuctionStatus.FINISHED) return;
283+
if (LocalDateTime.now().isBefore(paymentDeadline(item))) {
284+
schedulePaymentExpiry(itemId, paymentDeadline(item));
285+
return;
286+
}
287+
288+
User winnerUser = null;
289+
if (item.getCurrentWinnerId() != null) {
290+
winnerUser = userRepository.findById(item.getCurrentWinnerId()).orElse(null);
291+
if (winnerUser instanceof BannableUser bu) {
292+
bu.banTemporary(NON_PAYMENT_BAN_SECONDS);
293+
}
294+
}
295+
296+
item.setStatus(AuctionStatus.CANCELED);
297+
final User bannedWinner = winnerUser;
298+
runInTx(() -> {
299+
itemRepository.update(item);
300+
if (bannedWinner != null) userRepository.save(bannedWinner);
301+
});
302+
if (statusChangeCallback != null) statusChangeCallback.run();
303+
} finally {
304+
lock.unlock();
305+
}
306+
}
221307
}

0 commit comments

Comments
 (0)