44import com .auction .exception .UnauthorizedActionException ;
55import com .auction .model .Admin ;
66import com .auction .model .AuctionStatus ;
7+ import com .auction .model .BannableUser ;
78import com .auction .model .Bidder ;
89import com .auction .model .Item ;
910import com .auction .model .Seller ;
2223
2324public 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