diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/AdminController.java b/src/main/java/nl/andrewl/coyotecredit/ctl/AdminController.java new file mode 100644 index 0000000..c7fb791 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/AdminController.java @@ -0,0 +1,56 @@ +package nl.andrewl.coyotecredit.ctl; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.AddTradeablePayload; +import nl.andrewl.coyotecredit.model.User; +import nl.andrewl.coyotecredit.service.TradeableService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@Controller +@RequestMapping(path = "/admin") +@RequiredArgsConstructor +public class AdminController { + private final TradeableService tradeableService; + + @GetMapping + public String get(@AuthenticationPrincipal User user) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + return "admin/admin"; + } + + @GetMapping(path = "/manageTradeables") + public String getManageTradeablesPage(Model model, @AuthenticationPrincipal User user) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + model.addAttribute("tradeables", tradeableService.getAllPublicTradeables(user)); + return "admin/manage_tradeables"; + } + + @GetMapping(path = "/manageTradeables/add") + public String getAddTradeablePage(@AuthenticationPrincipal User user) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + return "admin/add_tradeable"; + } + + @PostMapping(path = "/manageTradeables/add") + public String addTradeable(@AuthenticationPrincipal User user, @ModelAttribute AddTradeablePayload payload) { + tradeableService.addTradeable(user, payload); + return "redirect:/admin/manageTradeables"; + } + + @GetMapping(path = "/manageTradeables/remove/{tradeableId}") + public String getRemoveTradeablePage(@AuthenticationPrincipal User user, @PathVariable long tradeableId) { + tradeableService.checkRemovePage(user, tradeableId); + return "admin/remove_tradeable"; + } + + @PostMapping(path = "/manageTradeables/remove/{tradeableId}") + public String removeTradeable(@AuthenticationPrincipal User user, @PathVariable long tradeableId) { + tradeableService.removeTradeable(user, tradeableId); + return "redirect:/admin/manageTradeables"; + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddTradeablePayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddTradeablePayload.java new file mode 100644 index 0000000..b91e84b --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddTradeablePayload.java @@ -0,0 +1,4 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record AddTradeablePayload(String name, String symbol, String type, String description) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java index 4eab4e8..e9bfa56 100644 --- a/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java +++ b/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java @@ -2,8 +2,10 @@ package nl.andrewl.coyotecredit.dao; import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.model.Exchange; +import nl.andrewl.coyotecredit.model.Tradeable; import nl.andrewl.coyotecredit.model.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -18,4 +20,9 @@ public interface AccountRepository extends JpaRepository { boolean existsByUserAndExchange(User user, Exchange exchange); List findAllByExchange(Exchange e); + + @Query("SELECT a FROM Account a " + + "LEFT JOIN FETCH a.balances bal " + + "WHERE bal.tradeable = :t") + List findAllWithBalanceForTradeable(Tradeable t); } diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java index 7502f1a..091d77c 100644 --- a/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java +++ b/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java @@ -9,4 +9,5 @@ import java.util.List; @Repository public interface TradeableRepository extends JpaRepository { List findAllByExchangeNull(); + boolean existsByExchangeNullAndSymbol(String symbol); } diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java index a9bf07d..d376147 100644 --- a/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java +++ b/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java @@ -1,12 +1,19 @@ package nl.andrewl.coyotecredit.dao; +import nl.andrewl.coyotecredit.model.Tradeable; import nl.andrewl.coyotecredit.model.Transaction; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface TransactionRepository extends JpaRepository { Page findAllByAccountNumberOrderByTimestampDesc(String accountNumber, Pageable pageable); + + @Modifying + @Query("DELETE FROM Transaction tx WHERE tx.from = :t OR tx.to = :t") + void deleteAllWithTradeable(Tradeable t); } diff --git a/src/main/java/nl/andrewl/coyotecredit/service/TradeableService.java b/src/main/java/nl/andrewl/coyotecredit/service/TradeableService.java index f28fa3f..b5666f7 100644 --- a/src/main/java/nl/andrewl/coyotecredit/service/TradeableService.java +++ b/src/main/java/nl/andrewl/coyotecredit/service/TradeableService.java @@ -1,21 +1,27 @@ package nl.andrewl.coyotecredit.service; import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.AddTradeablePayload; import nl.andrewl.coyotecredit.ctl.dto.TradeableData; -import nl.andrewl.coyotecredit.dao.AccountRepository; -import nl.andrewl.coyotecredit.dao.TradeableRepository; -import nl.andrewl.coyotecredit.model.Tradeable; -import nl.andrewl.coyotecredit.model.User; +import nl.andrewl.coyotecredit.dao.*; +import nl.andrewl.coyotecredit.model.*; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; + @Service @RequiredArgsConstructor public class TradeableService { private final TradeableRepository tradeableRepository; private final AccountRepository accountRepository; + private final ExchangeRepository exchangeRepository; + private final TransactionRepository transactionRepository; + private final TradeableUpdateService tradeableUpdateService; + private final UserNotificationRepository notificationRepository; @Transactional(readOnly = true) public TradeableData getTradeable(long id, User user) { @@ -26,4 +32,66 @@ public class TradeableService { } return new TradeableData(tradeable); } + + @Transactional(readOnly = true) + public List getAllPublicTradeables(User user) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + return tradeableRepository.findAllByExchangeNull() + .stream().map(TradeableData::new) + .toList(); + } + + @Transactional + public void addTradeable(User user, AddTradeablePayload payload) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + if (tradeableRepository.existsByExchangeNullAndSymbol(payload.symbol())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Tradeable with that symbol already exists."); + } + TradeableType type; + try { + type = TradeableType.valueOf(payload.type()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid type.", e); + } + Tradeable tradeable = new Tradeable(payload.symbol(), type, payload.name(), payload.description(), BigDecimal.ZERO, null); + tradeable = tradeableRepository.save(tradeable); + tradeableUpdateService.updateTradeable(tradeable); + } + + @Transactional(readOnly = true) + public void checkRemovePage(User user, long tradeableId) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + Tradeable t = tradeableRepository.findById(tradeableId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (t.getExchange() != null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + if (t.getSymbol().equalsIgnoreCase("USD")) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + + @Transactional + public void removeTradeable(User user, long tradeableId) { + if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + Tradeable t = tradeableRepository.findById(tradeableId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (t.getExchange() != null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove exchange-custom tradeable."); + if (t.getSymbol().equalsIgnoreCase("USD")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove USD."); + for (Account account : accountRepository.findAllWithBalanceForTradeable(t)) { + Balance bal = account.getBalanceForTradeable(t); + if (bal != null) { + account.getBalances().remove(bal); + notificationRepository.save(new UserNotification( + account.getUser(), + String.format("Your balance of %s %s in %s has been removed because Coyote Credit has removed support for it.", + bal.getAmount().toPlainString(), + t.getSymbol(), + account.getExchange().getName()) + )); + } + } + transactionRepository.deleteAllWithTradeable(t); + for (Exchange exchange : exchangeRepository.findAll()) { + exchange.getSupportedTradeables().remove(t); + exchangeRepository.save(exchange); + } + tradeableRepository.delete(t); + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/service/TradeableUpdateService.java b/src/main/java/nl/andrewl/coyotecredit/service/TradeableUpdateService.java index e98c531..ae74482 100644 --- a/src/main/java/nl/andrewl/coyotecredit/service/TradeableUpdateService.java +++ b/src/main/java/nl/andrewl/coyotecredit/service/TradeableUpdateService.java @@ -68,7 +68,7 @@ public class TradeableUpdateService { } } - private void updateTradeable(Tradeable tradeable) { + public void updateTradeable(Tradeable tradeable) { BigDecimal updatedValue = null; if (tradeable.getType().equals(TradeableType.STOCK)) { updatedValue = fetchStockClosePrice(tradeable.getSymbol()); diff --git a/src/main/resources/templates/admin/add_tradeable.html b/src/main/resources/templates/admin/add_tradeable.html new file mode 100644 index 0000000..40099b5 --- /dev/null +++ b/src/main/resources/templates/admin/add_tradeable.html @@ -0,0 +1,33 @@ + + +
+

Add Tradeable Asset

+

Add a new publicly tradeable asset for exchanges to use.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/src/main/resources/templates/admin/admin.html b/src/main/resources/templates/admin/admin.html new file mode 100644 index 0000000..5e6be3b --- /dev/null +++ b/src/main/resources/templates/admin/admin.html @@ -0,0 +1,14 @@ + + +
+

Admin

+

+ Control top-level application settings and data within this admin interface. +

+ + Manage Tradeable Assets +
diff --git a/src/main/resources/templates/admin/manage_tradeables.html b/src/main/resources/templates/admin/manage_tradeables.html new file mode 100644 index 0000000..e29df42 --- /dev/null +++ b/src/main/resources/templates/admin/manage_tradeables.html @@ -0,0 +1,50 @@ + + +
+

Manage Tradeable Assets

+

+ Manage the global collection of tradeable assets that exchanges may incorporate into their + systems. +

+ +
+
+
Tradeable Assets
+ + + + + + + + + + + + + + + + + + + +
NameSymbolPrice (USD)TypeRemove
+ + + Remove +
+ + Add + +
+
+
diff --git a/src/main/resources/templates/admin/remove_tradeable.html b/src/main/resources/templates/admin/remove_tradeable.html new file mode 100644 index 0000000..89454e3 --- /dev/null +++ b/src/main/resources/templates/admin/remove_tradeable.html @@ -0,0 +1,17 @@ + + +
+

Remove Tradeable

+

+ Are you sure you want to remove this tradeable? It will be removed from all exchanges, and + all accounts will have their balance of this tradeable removed permanently. +

+
+ +
+ Cancel +
\ No newline at end of file diff --git a/src/main/resources/templates/fragment/header.html b/src/main/resources/templates/fragment/header.html index c4f3fc7..4cdcde5 100644 --- a/src/main/resources/templates/fragment/header.html +++ b/src/main/resources/templates/fragment/header.html @@ -36,6 +36,9 @@ > +