diff --git a/src/main/java/nl/andrewl/coyotecredit/config/WebMvcConfig.java b/src/main/java/nl/andrewl/coyotecredit/config/WebMvcConfig.java new file mode 100644 index 0000000..adbf76a --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/config/WebMvcConfig.java @@ -0,0 +1,19 @@ +package nl.andrewl.coyotecredit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.time.Duration; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/static/**") + .addResourceLocations("classpath:/static/") + .setOptimizeLocations(true) + .setCacheControl(CacheControl.maxAge(Duration.ofHours(1))); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/config/WebSecurityConfig.java b/src/main/java/nl/andrewl/coyotecredit/config/WebSecurityConfig.java index c1b2623..05f7109 100644 --- a/src/main/java/nl/andrewl/coyotecredit/config/WebSecurityConfig.java +++ b/src/main/java/nl/andrewl/coyotecredit/config/WebSecurityConfig.java @@ -26,7 +26,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { http .authorizeRequests() .antMatchers( - "/login", "/login/processing" + "/login", "/login/processing", "/static/**" ).permitAll() .and() .authorizeRequests().anyRequest().authenticated() diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/AccountPage.java b/src/main/java/nl/andrewl/coyotecredit/ctl/AccountPage.java index df21012..dffe0d5 100644 --- a/src/main/java/nl/andrewl/coyotecredit/ctl/AccountPage.java +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/AccountPage.java @@ -1,14 +1,18 @@ package nl.andrewl.coyotecredit.ctl; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.service.AccountService; +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.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; @Controller @RequestMapping(path = "/accounts/{accountId}") @@ -26,4 +30,18 @@ public class AccountPage { model.addAttribute("account", data); return "account"; } + + @GetMapping(path = "/editBalances") + public String getEditBalancesPage(Model model, @PathVariable long accountId, @AuthenticationPrincipal User user) { + var data = accountService.getAccountData(user, accountId); + if (!data.userAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + model.addAttribute("account", data); + return "account/edit_balances"; + } + + @PostMapping(path = "/editBalances") + public String postEditBalances(@PathVariable long accountId, @AuthenticationPrincipal User user, @RequestParam MultiValueMap paramMap) { + accountService.editBalances(accountId, user, paramMap); + return "redirect:/accounts/" + accountId; + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangePage.java b/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangePage.java new file mode 100644 index 0000000..6d9811f --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/ExchangePage.java @@ -0,0 +1,53 @@ +package nl.andrewl.coyotecredit.ctl; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.AddAccountPayload; +import nl.andrewl.coyotecredit.model.User; +import nl.andrewl.coyotecredit.service.ExchangeService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping(path = "/exchanges/{exchangeId}") +@RequiredArgsConstructor +public class ExchangePage { + private final ExchangeService exchangeService; + + @GetMapping + public String get(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) { + model.addAttribute("exchange", exchangeService.getData(exchangeId, user)); + return "exchange"; + } + + @GetMapping(path = "/accounts") + public String getAccountsPage(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) { + model.addAttribute("accounts", exchangeService.getAccounts(exchangeId, user)); + return "exchange/accounts"; + } + + @GetMapping(path = "/addAccount") + public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) { + exchangeService.ensureAdminAccount(exchangeId, user); + return "exchange/addAccount"; + } + + @PostMapping(path = "/addAccount") + public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) { + long accountId = exchangeService.addAccount(exchangeId, user, payload); + return "redirect:/accounts/" + accountId; + } + + @GetMapping(path = "/removeAccount/{accountId}") + public String getRemoveAccountPage(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) { + exchangeService.ensureAdminAccount(exchangeId, user); + return "exchange/removeAccount"; + } + + @PostMapping(path = "/removeAccount/{accountId}") + public String postRemoveAccount(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) { + exchangeService.removeAccount(exchangeId, accountId, user); + return "redirect:/exchanges/" + exchangeId + "/accounts"; + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/TradePage.java b/src/main/java/nl/andrewl/coyotecredit/ctl/TradePage.java new file mode 100644 index 0000000..e29372a --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/TradePage.java @@ -0,0 +1,41 @@ +package nl.andrewl.coyotecredit.ctl; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.TradePayload; +import nl.andrewl.coyotecredit.model.User; +import nl.andrewl.coyotecredit.service.ExchangeService; +import nl.andrewl.coyotecredit.service.TradeService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@Controller +@RequestMapping(path = "/trade/{accountId}") +@RequiredArgsConstructor +public class TradePage { + private final TradeService tradeService; + private final ExchangeService exchangeService; + + @GetMapping + public String get( + Model model, + @PathVariable long accountId, + @AuthenticationPrincipal User user + ) { + model.addAttribute("data", tradeService.getTradeData(accountId, user)); + return "trade"; + } + + @PostMapping + public String doTrade( + @PathVariable long accountId, + @AuthenticationPrincipal User user, + @ModelAttribute @Valid TradePayload payload + ) { + exchangeService.doTrade(accountId, payload, user); + return "redirect:/accounts/" + accountId; + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/UserPage.java b/src/main/java/nl/andrewl/coyotecredit/ctl/UserPage.java new file mode 100644 index 0000000..52e40f5 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/UserPage.java @@ -0,0 +1,24 @@ +package nl.andrewl.coyotecredit.ctl; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.model.User; +import nl.andrewl.coyotecredit.service.UserService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping(path = "/users/{userId}") +@RequiredArgsConstructor +public class UserPage { + private final UserService userService; + + @GetMapping + public String get(Model model, @PathVariable long userId, @AuthenticationPrincipal User user) { + model.addAttribute("user", userService.getUser(userId, user)); + return "user"; + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/api/ExchangeApiController.java b/src/main/java/nl/andrewl/coyotecredit/ctl/api/ExchangeApiController.java new file mode 100644 index 0000000..7356432 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/api/ExchangeApiController.java @@ -0,0 +1,22 @@ +package nl.andrewl.coyotecredit.ctl.api; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.service.ExchangeService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/exchanges/{exchangeId}") +@RequiredArgsConstructor +public class ExchangeApiController { + private final ExchangeService exchangeService; + + @GetMapping(path = "/tradeables") + public Map getCurrentTradeables(@PathVariable long exchangeId) { + return exchangeService.getCurrentTradeables(exchangeId); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java new file mode 100644 index 0000000..e37d89a --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/AddAccountPayload.java @@ -0,0 +1,7 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record AddAccountPayload( + String name, + String username, + String password +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/BalanceData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/BalanceData.java new file mode 100644 index 0000000..bf34450 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/BalanceData.java @@ -0,0 +1,7 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record BalanceData( + long id, + String symbol, + String amount +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/ExchangeData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/ExchangeData.java new file mode 100644 index 0000000..5410917 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/ExchangeData.java @@ -0,0 +1,8 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record ExchangeData( + long id, + String name, + String primaryTradeable +) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullAccountData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullAccountData.java new file mode 100644 index 0000000..0b53cb4 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullAccountData.java @@ -0,0 +1,19 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import java.util.List; + +/** + * The data that's needed for displaying a user's account page. + */ +public record FullAccountData ( + long id, + String number, + String name, + boolean admin, + boolean userAdmin, + ExchangeData exchange, + List balances, + String totalBalance, + List recentTransactions +) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java new file mode 100644 index 0000000..2f47d42 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/FullExchangeData.java @@ -0,0 +1,16 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import java.util.List; + +/** + * The data that's used on the exchange page. + */ +public record FullExchangeData ( + long id, + String name, + TradeableData primaryTradeable, + List supportedTradeables, + // Account info + boolean accountAdmin +) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java new file mode 100644 index 0000000..932b17a --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/SimpleAccountData.java @@ -0,0 +1,10 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record SimpleAccountData ( + long id, + String number, + String name, + boolean admin, + String totalBalance +) { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeData.java new file mode 100644 index 0000000..434fc9e --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeData.java @@ -0,0 +1,14 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public record TradeData( + long accountId, + String accountNumber, + long exchangeId, + List tradeablesToSell, + Map accountBalances, + List tradeablesToBuy +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradePayload.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradePayload.java new file mode 100644 index 0000000..0e1d0f8 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradePayload.java @@ -0,0 +1,23 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +/** + * The payload that's sent when a user performs a trade with the market. This + * can be either a SELL or a BUY trade. + * + *

+ * In a SELL trade, the user indicates an amount of their selling tradeable + * to sell in exchange for the market-equivalent value of the buying + * tradeable. + *

+ *

+ * In a BUY trade, the opposite happens, where the user indicates how much + * of the buying tradeable they want to acquire, and will have a market- + * equivalent value deducted from their selling tradeable. + *

+ */ +public record TradePayload( + String type, // SELL or BUY + long sellTradeableId, + long buyTradeableId, + String value +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeableData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeableData.java new file mode 100644 index 0000000..36aa911 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TradeableData.java @@ -0,0 +1,23 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import nl.andrewl.coyotecredit.model.Tradeable; + +public record TradeableData( + long id, + String symbol, + String type, + String marketPriceUsd, + String name, + String description +) { + public TradeableData(Tradeable t) { + this( + t.getId(), + t.getSymbol(), + t.getType().name(), + t.getMarketPriceUsd().toPlainString(), + t.getName(), + t.getDescription() + ); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TransactionData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TransactionData.java new file mode 100644 index 0000000..c380dc4 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/TransactionData.java @@ -0,0 +1,25 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +import nl.andrewl.coyotecredit.model.Transaction; + +import java.time.format.DateTimeFormatter; + +public record TransactionData( + long id, + TradeableData from, + String fromAmount, + TradeableData to, + String toAmount, + String timestamp +) { + public TransactionData(Transaction t) { + this( + t.getId(), + new TradeableData(t.getFrom()), + t.getFromAmount().toPlainString(), + new TradeableData(t.getTo()), + t.getToAmount().toPlainString(), + t.getTimestamp().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java new file mode 100644 index 0000000..75d8746 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/ctl/dto/UserData.java @@ -0,0 +1,6 @@ +package nl.andrewl.coyotecredit.ctl.dto; + +public record UserData ( + long id, + String username +) {} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java index ee4be6d..fc2ad71 100644 --- a/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java +++ b/src/main/java/nl/andrewl/coyotecredit/dao/AccountRepository.java @@ -1,13 +1,17 @@ package nl.andrewl.coyotecredit.dao; import nl.andrewl.coyotecredit.model.Account; +import nl.andrewl.coyotecredit.model.Exchange; import nl.andrewl.coyotecredit.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface AccountRepository extends JpaRepository { List findAllByUser(User user); + Optional findByNumber(String number); + Optional findByUserAndExchange(User user, Exchange exchange); } diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeRepository.java new file mode 100644 index 0000000..b6f3bf6 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/dao/ExchangeRepository.java @@ -0,0 +1,9 @@ +package nl.andrewl.coyotecredit.dao; + +import nl.andrewl.coyotecredit.model.Exchange; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExchangeRepository extends JpaRepository { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java new file mode 100644 index 0000000..4dd4016 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/dao/TradeableRepository.java @@ -0,0 +1,9 @@ +package nl.andrewl.coyotecredit.dao; + +import nl.andrewl.coyotecredit.model.Tradeable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TradeableRepository extends JpaRepository { +} diff --git a/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java b/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java new file mode 100644 index 0000000..a9bf07d --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/dao/TransactionRepository.java @@ -0,0 +1,12 @@ +package nl.andrewl.coyotecredit.dao; + +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.stereotype.Repository; + +@Repository +public interface TransactionRepository extends JpaRepository { + Page findAllByAccountNumberOrderByTimestampDesc(String accountNumber, Pageable pageable); +} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Account.java b/src/main/java/nl/andrewl/coyotecredit/model/Account.java index 62e35aa..c8f1032 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/Account.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/Account.java @@ -6,7 +6,9 @@ import lombok.NoArgsConstructor; import javax.persistence.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -22,23 +24,72 @@ public class Account { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The unique account number. + */ @Column(nullable = false, unique = true) private String number; + /** + * The user that this account belongs to. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) private User user; + /** + * The name on this account. + */ + @Column + private String name; + + /** + * The exchange that this account belongs to. + */ @ManyToOne(fetch = FetchType.LAZY, optional = false) private Exchange exchange; + /** + * Whether this account is an administrator in the exchange it's linked to. + * Administrators have special permissions to add and remove other accounts, + * custom tradeables, exchange rates, and more. + */ + @Column(nullable = false) + private boolean admin; + + /** + * The set of tradeable balances that this account has. + */ @OneToMany(mappedBy = "account", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private Set balances; - public Map getMappedBalances() { - Map b = new HashMap<>(); + public Account(String number, User user, String name, Exchange exchange) { + this.number = number; + this.user = user; + this.name = name; + this.exchange = exchange; + this.balances = new HashSet<>(); + } + + public Map getMappedBalances() { + Map b = new HashMap<>(); for (var bal : getBalances()) { - b.put(bal.getCurrency(), bal.getAmount()); + b.put(bal.getTradeable(), bal.getAmount()); } return b; } + + public Balance getBalanceForTradeable(Tradeable t) { + for (var bal : getBalances()) { + if (bal.getTradeable().equals(t)) return bal; + } + return null; + } + + public BigDecimal getTotalBalance() { + BigDecimal totalUsd = new BigDecimal(0); + for (var bal : getBalances()) { + totalUsd = totalUsd.add(bal.getTradeable().getMarketPriceUsd().multiply(bal.getAmount())); + } + return totalUsd.divide(getExchange().getPrimaryTradeable().getMarketPriceUsd(), RoundingMode.HALF_UP); + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Balance.java b/src/main/java/nl/andrewl/coyotecredit/model/Balance.java index cfb4f93..593db1b 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/Balance.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/Balance.java @@ -1,24 +1,43 @@ package nl.andrewl.coyotecredit.model; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import javax.persistence.*; import java.math.BigDecimal; +/** + * Represents an account's balance for a certain amount of tradeables in their + * exchange. + */ @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "account_balance") @Getter public class Balance { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @EmbeddedId + private BalanceId balanceId; + @MapsId("accountId") @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") private Account account; + @MapsId("tradeableId") @ManyToOne(optional = false, fetch = FetchType.EAGER) - private Currency currency; + @JoinColumn(name = "tradeable_id") + private Tradeable tradeable; @Column(nullable = false, precision = 24, scale = 10) + @Setter private BigDecimal amount; + + public Balance(Account account, Tradeable tradeable, BigDecimal amount) { + this.balanceId = new BalanceId(tradeable.getId(), account.getId()); + this.account = account; + this.tradeable = tradeable; + this.amount = amount; + } } diff --git a/src/main/java/nl/andrewl/coyotecredit/model/BalanceId.java b/src/main/java/nl/andrewl/coyotecredit/model/BalanceId.java new file mode 100644 index 0000000..4b87cab --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/model/BalanceId.java @@ -0,0 +1,17 @@ +package nl.andrewl.coyotecredit.model; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Embeddable; +import java.io.Serializable; + +@Embeddable +@EqualsAndHashCode +@AllArgsConstructor +@NoArgsConstructor +public class BalanceId implements Serializable { + private Long tradeableId; + private Long accountId; +} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Currency.java b/src/main/java/nl/andrewl/coyotecredit/model/Currency.java deleted file mode 100644 index 8dc2f00..0000000 --- a/src/main/java/nl/andrewl/coyotecredit/model/Currency.java +++ /dev/null @@ -1,48 +0,0 @@ -package nl.andrewl.coyotecredit.model; - -import lombok.Getter; - -import javax.persistence.*; -import java.util.Objects; - -/** - * Represents a type of currency. This can be an actual fiat currency, or - * perhaps a cryptocurrency, or stocks, or really anything tradeable on the - * exchange. - */ -@Entity -@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"identifier", "type"})) -@Getter -public class Currency { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String identifier; - - @Enumerated(EnumType.STRING) - private CurrencyType type; - - @Column(nullable = false) - private String name; - - @Column - private String description; - - @Column(nullable = false) - private float minDenomination = 0.01f; - - @Override - public boolean equals(Object other) { - if (!(other instanceof Currency c)) return false; - if (c.getId() != null && this.getId() != null) return this.getId().equals(c.getId()); - return this.identifier.equals(c.getIdentifier()) && - this.type.equals(c.getType()); - } - - @Override - public int hashCode() { - return Objects.hash(this.identifier, this.type); - } -} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/CustomTradeable.java b/src/main/java/nl/andrewl/coyotecredit/model/CustomTradeable.java new file mode 100644 index 0000000..4a0d5e5 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/model/CustomTradeable.java @@ -0,0 +1,20 @@ +package nl.andrewl.coyotecredit.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.ManyToOne; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CustomTradeable extends Tradeable { + /** + * The exchange that the tradeable belongs to. + */ + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Exchange exchange; +} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java b/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java index c42590c..727d897 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/Exchange.java @@ -1,15 +1,19 @@ package nl.andrewl.coyotecredit.model; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import javax.persistence.*; import java.util.HashSet; +import java.util.Objects; import java.util.Set; /** * Represents a large collection of users that interact with each other. */ @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Exchange { @Id @@ -22,15 +26,50 @@ public class Exchange { @Column(nullable = false) private String name; - @OneToMany(mappedBy = "exchange") - private Set currencyPairs; + /** + * The primary tradeable that's used by this exchange. + */ + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Tradeable primaryTradeable; - public Set getSupportedCurrencies() { - Set currencies = new HashSet<>(); - for (var pair : getCurrencyPairs()) { - currencies.add(pair.getFromCurrency()); - currencies.add(pair.getToCurrency()); - } - return currencies; + /** + * The set of tradeables that this exchange allows users to interact with. + */ + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "exchange_supported_tradeable", + joinColumns = @JoinColumn(name = "exchange_id"), + inverseJoinColumns = @JoinColumn(name = "tradeable_id") + ) + private Set supportedTradeables; + + /** + * The set of custom tradeables created specifically for use in this exchange. + */ + @OneToMany(mappedBy = "exchange", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Set customTradeables; + + /** + * The set of accounts that are registered with this exchange. + */ + @OneToMany(mappedBy = "exchange", fetch = FetchType.LAZY) + private Set accounts; + + public Set getAllTradeables() { + Set s = new HashSet<>(); + s.addAll(getSupportedTradeables()); + s.addAll(getCustomTradeables()); + return s; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Exchange e)) return false; + return this.getId() != null && e.getId() != null && this.getId().equals(e.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(this.getId()); } } diff --git a/src/main/java/nl/andrewl/coyotecredit/model/ExchangePair.java b/src/main/java/nl/andrewl/coyotecredit/model/ExchangePair.java deleted file mode 100644 index bd1491c..0000000 --- a/src/main/java/nl/andrewl/coyotecredit/model/ExchangePair.java +++ /dev/null @@ -1,30 +0,0 @@ -package nl.andrewl.coyotecredit.model; - -import lombok.Getter; - -import javax.persistence.*; -import java.math.BigDecimal; - -/** - * Represents a pair of currencies that can be exchanged at a set exchange rate. - */ -@Entity -@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"from_currency_id", "to_currency_id", "exchange_id"})) -@Getter -public class ExchangePair { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(optional = false) - private Exchange exchange; - - @ManyToOne(optional = false) - private Currency fromCurrency; - - @ManyToOne(optional = false) - private Currency toCurrency; - - @Column(nullable = false, precision = 24, scale = 10) - private BigDecimal exchangeRate = new BigDecimal("1.0"); -} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Tradeable.java b/src/main/java/nl/andrewl/coyotecredit/model/Tradeable.java new file mode 100644 index 0000000..c35ce20 --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/model/Tradeable.java @@ -0,0 +1,59 @@ +package nl.andrewl.coyotecredit.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Represents a type of currency. This can be an actual fiat currency, or + * perhaps a cryptocurrency, or stocks, or really anything tradeable on the + * exchange. + */ +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Tradeable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String symbol; + + @Enumerated(EnumType.STRING) + private TradeableType type; + + @Column(nullable = false, precision = 24, scale = 10) + private BigDecimal marketPriceUsd = new BigDecimal(1); + + @Column(nullable = false) + private String name; + + @Column + private String description; + + public Tradeable(String symbol, TradeableType type, String name, String description, BigDecimal marketPriceUsd) { + this.symbol = symbol; + this.type = type; + this.name = name; + this.description = description; + this.marketPriceUsd = marketPriceUsd; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Tradeable c)) return false; + if (this.getId() != null && c.getId() != null) return this.getId().equals(c.getId()); + return this.getSymbol().equals(c.getSymbol()) && this.getType().equals(c.getType()); + } + + @Override + public int hashCode() { + return Objects.hash(this.id, this.symbol); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/CurrencyType.java b/src/main/java/nl/andrewl/coyotecredit/model/TradeableType.java similarity index 69% rename from src/main/java/nl/andrewl/coyotecredit/model/CurrencyType.java rename to src/main/java/nl/andrewl/coyotecredit/model/TradeableType.java index 31a4644..8671d93 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/CurrencyType.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/TradeableType.java @@ -1,6 +1,6 @@ package nl.andrewl.coyotecredit.model; -public enum CurrencyType { +public enum TradeableType { FIAT, CRYPTO, STOCK diff --git a/src/main/java/nl/andrewl/coyotecredit/model/Transaction.java b/src/main/java/nl/andrewl/coyotecredit/model/Transaction.java new file mode 100644 index 0000000..1f0093d --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/model/Transaction.java @@ -0,0 +1,53 @@ +package nl.andrewl.coyotecredit.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Represents a permanent record of a trade transaction. + */ +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Transaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String accountNumber; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Exchange exchange; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Tradeable from; + + @Column(nullable = false, precision = 24, scale = 10) + private BigDecimal fromAmount; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private Tradeable to; + + @Column(nullable = false, precision = 24, scale = 10) + private BigDecimal toAmount; + + @Column(nullable = false, updatable = false) + private LocalDateTime timestamp; + + public Transaction(String accountNumber, Exchange exchange, Tradeable from, BigDecimal fromAmount, Tradeable to, BigDecimal toAmount, LocalDateTime timestamp) { + this.accountNumber = accountNumber; + this.exchange = exchange; + this.from = from; + this.fromAmount = fromAmount; + this.to = to; + this.toAmount = toAmount; + this.timestamp = timestamp; + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/model/User.java b/src/main/java/nl/andrewl/coyotecredit/model/User.java index ce4bf1b..9a2ec69 100644 --- a/src/main/java/nl/andrewl/coyotecredit/model/User.java +++ b/src/main/java/nl/andrewl/coyotecredit/model/User.java @@ -1,18 +1,22 @@ package nl.andrewl.coyotecredit.model; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import javax.persistence.*; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.Set; /** * Represents a basic user in the system. */ @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class User implements UserDetails { @Id @@ -31,6 +35,11 @@ public class User implements UserDetails { @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private Set accounts; + public User(String username, String passwordHash) { + this.username = username; + this.passwordHash = passwordHash; + this.accounts = new HashSet<>(); + } // USER DETAILS IMPLEMENTATION diff --git a/src/main/java/nl/andrewl/coyotecredit/service/AccountService.java b/src/main/java/nl/andrewl/coyotecredit/service/AccountService.java index d6c349d..c08f350 100644 --- a/src/main/java/nl/andrewl/coyotecredit/service/AccountService.java +++ b/src/main/java/nl/andrewl/coyotecredit/service/AccountService.java @@ -1,22 +1,34 @@ package nl.andrewl.coyotecredit.service; -import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.BalanceData; +import nl.andrewl.coyotecredit.ctl.dto.ExchangeData; +import nl.andrewl.coyotecredit.ctl.dto.FullAccountData; +import nl.andrewl.coyotecredit.ctl.dto.TransactionData; import nl.andrewl.coyotecredit.dao.AccountRepository; +import nl.andrewl.coyotecredit.dao.TradeableRepository; +import nl.andrewl.coyotecredit.dao.TransactionRepository; import nl.andrewl.coyotecredit.model.Account; +import nl.andrewl.coyotecredit.model.Balance; +import nl.andrewl.coyotecredit.model.Tradeable; import nl.andrewl.coyotecredit.model.User; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.MultiValueMap; import org.springframework.web.server.ResponseStatusException; -import java.util.ArrayList; +import java.math.BigDecimal; +import java.util.Comparator; import java.util.List; @Service @RequiredArgsConstructor public class AccountService { private final AccountRepository accountRepository; + private final TransactionRepository transactionRepository; + private final TradeableRepository tradeableRepository; public static record AccountData ( long id, @@ -27,40 +39,68 @@ public class AccountService { @Transactional(readOnly = true) public List getAccountsOverview(User user) { return accountRepository.findAllByUser(user).stream() - .map(a -> new AccountData(a.getId(), a.getNumber(), a.getExchange().getName())) + .map(a -> new AccountData( + a.getId(), + a.getNumber(), + a.getExchange().getName() + )) .toList(); } - public static class FullAccountData { - public long id; - public String number; - public String exchangeName; - public List balances; - } - - @AllArgsConstructor - public static class BalanceData { - public String currencyIdentifier; - public String amount; - } - @Transactional(readOnly = true) public FullAccountData getAccountData(User user, long accountId) { Account account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - - if (!account.getUser().getId().equals(user.getId())) { + Account userAccount = accountRepository.findByUserAndExchange(user, account.getExchange()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!userAccount.isAdmin() && !account.getUser().getId().equals(user.getId())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } - FullAccountData d = new FullAccountData(); - d.id = account.getId(); - d.number = account.getNumber(); - d.exchangeName = account.getExchange().getName(); - List balanceData = new ArrayList<>(); - for (var bal : account.getBalances()) { - balanceData.add(new BalanceData(bal.getCurrency().getIdentifier(), bal.getAmount().toPlainString())); + List transactionData = transactionRepository.findAllByAccountNumberOrderByTimestampDesc(account.getNumber(), PageRequest.of(0, 5)) + .map(TransactionData::new) + .stream().toList(); + return new FullAccountData( + account.getId(), + account.getNumber(), + account.getName(), + account.isAdmin(), + userAccount.isAdmin(), + new ExchangeData( + account.getExchange().getId(), + account.getExchange().getName(), + account.getExchange().getPrimaryTradeable().getSymbol() + ), + account.getBalances().stream() + .map(b -> new BalanceData(b.getTradeable().getId(), b.getTradeable().getSymbol(), b.getAmount().toPlainString())) + .sorted(Comparator.comparing(BalanceData::symbol)) + .toList(), + account.getTotalBalance().toPlainString(), + transactionData + ); + } + + @Transactional + public void editBalances(long accountId, User user, MultiValueMap paramMap) { + Account account = accountRepository.findById(accountId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account userAccount = accountRepository.findByUserAndExchange(user, account.getExchange()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!userAccount.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + for (var entry : paramMap.entrySet()) { + if (entry.getKey().startsWith("tradeable-")) { + long tradeableId = Long.parseLong(entry.getKey().substring(10)); + Tradeable tradeable = tradeableRepository.findById(tradeableId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + BigDecimal value = new BigDecimal(entry.getValue().get(0)); + Balance bal = account.getBalanceForTradeable(tradeable); + if (bal == null) { + bal = new Balance(account, tradeable, value); + account.getBalances().add(bal); + } else { + bal.setAmount(value); + } + } } - d.balances = balanceData; - return d; + accountRepository.save(account); } } diff --git a/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java b/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java new file mode 100644 index 0000000..d95c95a --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/service/ExchangeService.java @@ -0,0 +1,191 @@ +package nl.andrewl.coyotecredit.service; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.*; +import nl.andrewl.coyotecredit.dao.*; +import nl.andrewl.coyotecredit.model.*; +import nl.andrewl.coyotecredit.util.AccountNumberUtils; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ExchangeService { + private final ExchangeRepository exchangeRepository; + private final AccountRepository accountRepository; + private final TransactionRepository transactionRepository; + private final TradeableRepository tradeableRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public FullExchangeData getData(long exchangeId, User user) { + Exchange e = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, e) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return new FullExchangeData( + e.getId(), + e.getName(), + new TradeableData(e.getPrimaryTradeable()), + e.getAllTradeables().stream() + .map(TradeableData::new) + .sorted(Comparator.comparing(TradeableData::symbol)) + .toList(), + account.isAdmin() + ); + } + + @Transactional(readOnly = true) + public List getAccounts(long exchangeId, User user) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, exchange) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.isAdmin()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + DecimalFormat df = new DecimalFormat("#,###0.00"); + return exchange.getAccounts().stream() + .sorted(Comparator.comparing(Account::getName)) + .map(a -> new SimpleAccountData( + a.getId(), + a.getNumber(), + a.getName(), + a.isAdmin(), + df.format(a.getTotalBalance()) + ' ' + exchange.getPrimaryTradeable().getSymbol() + )) + .toList(); + } + + @Transactional(readOnly = true) + public void ensureAdminAccount(long exchangeId, User user) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, exchange) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.isAdmin()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + } + + @Transactional + public long addAccount(long exchangeId, User user, AddAccountPayload payload) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findByUserAndExchange(user, exchange) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.isAdmin()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + User u = userRepository.save(new User(payload.username(), passwordEncoder.encode(payload.password()))); + Account a = accountRepository.save(new Account( + AccountNumberUtils.generate(), + u, + payload.name(), + exchange + )); + for (var t : exchange.getAllTradeables()) { + a.getBalances().add(new Balance(a, t, BigDecimal.ZERO)); + } + a = accountRepository.save(a); + return a.getId(); + } + + @Transactional + public void removeAccount(long exchangeId, long accountId, User user) { + Exchange exchange = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account account = accountRepository.findById(accountId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Account userAccount = accountRepository.findByUserAndExchange(user, exchange) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!userAccount.isAdmin()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + accountRepository.delete(account); + } + + @Transactional(readOnly = true) + public Map getCurrentTradeables(long exchangeId) { + Exchange e = exchangeRepository.findById(exchangeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + Map tradeables = new HashMap<>(); + for (var t : e.getAllTradeables()) { + tradeables.put(t.getId(), t.getMarketPriceUsd().toPlainString()); + } + return tradeables; + } + + @Transactional + public void doTrade(long accountId, TradePayload payload, User user) { + Account account = accountRepository.findById(accountId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Account not found.")); + if (!account.getUser().getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + Exchange exchange = account.getExchange(); + Tradeable from = tradeableRepository.findById(payload.sellTradeableId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Sell tradeable not found.")); + Tradeable to = tradeableRepository.findById(payload.buyTradeableId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Buy tradeable not found.")); + BigDecimal value = new BigDecimal(payload.value()); + if (from.getType().equals(TradeableType.STOCK)) { + if (!payload.type().equalsIgnoreCase("SELL")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Can only perform SELL operations when selling stocks."); + } + if (to.getType().equals(TradeableType.STOCK)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot sell stock to purchase stock."); + } + } + if (to.getType().equals(TradeableType.STOCK)) { + if (!payload.type().equalsIgnoreCase("BUY")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Can only perform BUY operations when buying stocks."); + } + if (from.getType().equals(TradeableType.STOCK)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot sell stock to purchase stock."); + } + } + if (value.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only positive value may be specified."); + } + + BigDecimal fromValue; + BigDecimal toValue; + if (payload.type().equalsIgnoreCase("SELL")) { + fromValue = value; + toValue = fromValue.multiply(from.getMarketPriceUsd()).divide(to.getMarketPriceUsd(), RoundingMode.HALF_UP); + } else { + toValue = value; + fromValue = toValue.multiply(to.getMarketPriceUsd()).divide(from.getMarketPriceUsd(), RoundingMode.HALF_UP); + } + Balance fromBalance = account.getBalanceForTradeable(from); + if (fromBalance == null || fromBalance.getAmount().compareTo(fromValue) < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing required balance of " + fromValue.toPlainString() + " from " + from.getSymbol()); + } + fromBalance.setAmount(fromBalance.getAmount().subtract(fromValue)); + Balance toBalance = account.getBalanceForTradeable(to); + if (toBalance == null) { + toBalance = new Balance(account, to, toValue); + account.getBalances().add(toBalance); + } else { + toBalance.setAmount(toBalance.getAmount().add(toValue)); + } + accountRepository.save(account); + Transaction tx = new Transaction(account.getNumber(), exchange, from, fromValue, to, toValue, LocalDateTime.now(ZoneOffset.UTC)); + transactionRepository.save(tx); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/service/TradeService.java b/src/main/java/nl/andrewl/coyotecredit/service/TradeService.java new file mode 100644 index 0000000..37cff7f --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/service/TradeService.java @@ -0,0 +1,42 @@ +package nl.andrewl.coyotecredit.service; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.TradeData; +import nl.andrewl.coyotecredit.ctl.dto.TradeableData; +import nl.andrewl.coyotecredit.dao.AccountRepository; +import nl.andrewl.coyotecredit.model.Account; +import nl.andrewl.coyotecredit.model.User; +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.*; + +@Service +@RequiredArgsConstructor +public class TradeService { + private final AccountRepository accountRepository; + + @Transactional(readOnly = true) + public TradeData getTradeData(long accountId, User user) { + Account account = accountRepository.findById(accountId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!account.getUser().getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + List sellList = new ArrayList<>(); + Map accountBalances = new HashMap<>(); + for (var bal : account.getBalances()) { + sellList.add(new TradeableData(bal.getTradeable())); + accountBalances.put(bal.getTradeable().getSymbol(), bal.getAmount()); + } + sellList.sort(Comparator.comparing(TradeableData::symbol)); + List buyList = new ArrayList<>(); + for (var t : account.getExchange().getAllTradeables()) { + buyList.add(new TradeableData(t)); + } + + return new TradeData(account.getId(), account.getNumber(), account.getExchange().getId(), sellList, accountBalances, buyList); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/service/UserService.java b/src/main/java/nl/andrewl/coyotecredit/service/UserService.java new file mode 100644 index 0000000..8ebd95c --- /dev/null +++ b/src/main/java/nl/andrewl/coyotecredit/service/UserService.java @@ -0,0 +1,27 @@ +package nl.andrewl.coyotecredit.service; + +import lombok.RequiredArgsConstructor; +import nl.andrewl.coyotecredit.ctl.dto.UserData; +import nl.andrewl.coyotecredit.dao.UserRepository; +import nl.andrewl.coyotecredit.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + @Transactional + public UserData getUser(long userId, User requestingUser) { + User user; + if (requestingUser.getId().equals(userId)) { + user = requestingUser; + } else { + user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } + return new UserData(user.getId(), user.getUsername()); + } +} diff --git a/src/main/java/nl/andrewl/coyotecredit/util/AccountNumberUtils.java b/src/main/java/nl/andrewl/coyotecredit/util/AccountNumberUtils.java index aee5ef2..198900e 100644 --- a/src/main/java/nl/andrewl/coyotecredit/util/AccountNumberUtils.java +++ b/src/main/java/nl/andrewl/coyotecredit/util/AccountNumberUtils.java @@ -8,7 +8,7 @@ public class AccountNumberUtils { StringBuilder sb = new StringBuilder(19); Random rand = new SecureRandom(); for (int i = 0; i < 16; i++) { - if (i % 4 == 0) sb.append('-'); + if (i > 0 && i % 4 == 0) sb.append('-'); sb.append(rand.nextInt(0, 10)); } return sb.toString(); diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/js/trade.js b/src/main/resources/static/js/trade.js new file mode 100644 index 0000000..6eab3a6 --- /dev/null +++ b/src/main/resources/static/js/trade.js @@ -0,0 +1,155 @@ +const sellValueInput = document.getElementById("sellValueInput"); +const sellTradeableSelect = document.getElementById("sellTradeableSelect"); +const sellTradeableSelectText = document.getElementById("sellTradeableSelectText"); +const buyValueInput = document.getElementById("buyValueInput"); +const buyTradeableSelect = document.getElementById("buyTradeableSelect"); +const submitButton = document.getElementById("submitButton"); + +sellTradeableSelect.selectedIndex = null; +buyTradeableSelect.selectedIndex = null; + +sellTradeableSelect.addEventListener("change", onSellSelectChanged); +sellValueInput.addEventListener("change", onSellInputChanged); +buyTradeableSelect.addEventListener("change", onBuySelectChanged); +buyValueInput.addEventListener("change", onBuyInputChanged); +submitButton.addEventListener("click", onSubmitClicked); + +let tradeables = {}; +const exchangeId = Number(document.getElementById("exchangeIdInput").value); +const accountId = Number(document.getElementById("accountIdInput").value); +refreshTradeables(); +window.setInterval(refreshTradeables, 10000); + +function refreshTradeables() { + fetch("/api/exchanges/" + exchangeId + "/tradeables") + .then((response) => { + if (response.status !== 200) { + console.error("Exchange API call failed."); + } else { + response.json().then((data) => { + tradeables = data; + }); + } + }); +} + +function getSelectedSellOption() { + return sellTradeableSelect.options[sellTradeableSelect.selectedIndex]; +} + +function getSelectedBuyOption() { + return buyTradeableSelect.options[buyTradeableSelect.selectedIndex]; +} + +function updateInputSettings(input, step, min, max) { + input.setAttribute("step", step); + input.setAttribute("min", min); + input.setAttribute("max", max); +} + +function resetValueInputs() { + sellValueInput.value = null; + buyValueInput.value = null; +} + +// Event handlers + +function onSellSelectChanged() { + resetValueInputs(); + const sellOption = getSelectedSellOption(); + const sellType = sellOption.dataset.type; + const sellBalance = Number(sellOption.dataset.balance); + + // Don't allow the user to buy the same thing they're selling. + for (let i = 0; i < buyTradeableSelect.length; i++) { + const buyOption = buyTradeableSelect.options[i]; + const buyType = buyOption.dataset.type; + + let isOptionDisabled = buyOption.value === sellOption.value; + if (sellType === "STOCK" && buyType === "STOCK") { + isOptionDisabled = true; + } + + if (isOptionDisabled) { + buyOption.setAttribute("disabled", true); + } else { + buyOption.removeAttribute("disabled"); + } + // If the user is currently selecting an invalid choice, reset. + if (i === buyTradeableSelect.selectedIndex && isOptionDisabled) { + buyTradeableSelect.value = ""; + } + } + + // Update the sell value input for the current type's parameters. + if (sellType === "STOCK") { + updateInputSettings(sellValueInput, 1, 0, sellBalance); + buyValueInput.setAttribute("readonly", true); + } else if (sellType === "FIAT" || sellType === "CRYPTO") { + updateInputSettings(sellValueInput, 0.0000000001, 0, sellBalance); + buyValueInput.removeAttribute("readonly"); + } + + // Update the subtext to show the value. + sellTradeableSelectText.innerText = `Balance: ${sellBalance}`; + sellTradeableSelectText.removeAttribute("hidden"); +} + +function onBuySelectChanged() { + resetValueInputs(); + const buyOption = getSelectedBuyOption(); + const buyType = buyOption.dataset.type; + + if (buyType === "STOCK") { + updateInputSettings(buyValueInput, 1, 0, 1000000); + sellValueInput.setAttribute("readonly", true); + } else if (buyType === "FIAT" || buyType === "CRYPTO") { + updateInputSettings(buyValueInput, 0.0000000001, 0, 1000000); + sellValueInput.removeAttribute("readonly"); + } +} + +function onSellInputChanged() { + const sellOption = getSelectedSellOption(); + const buyOption = getSelectedBuyOption(); + const sellId = sellOption.value; + const buyId = buyOption.value; + const sellPriceUsd = tradeables[sellId]; + const buyPriceUsd = tradeables[buyId]; + + if (sellPriceUsd && buyPriceUsd) { + const sellVolume = Number(sellValueInput.value); + buyValueInput.value = (sellPriceUsd * sellVolume) / buyPriceUsd; + } +} + +function onBuyInputChanged() { + const sellOption = getSelectedSellOption(); + const buyOption = getSelectedBuyOption(); + const sellId = sellOption.value; + const buyId = buyOption.value; + const sellPriceUsd = tradeables[sellId]; + const buyPriceUsd = tradeables[buyId]; + + if (sellPriceUsd && buyPriceUsd) { + const buyVolume = Number(buyValueInput.value); + sellValueInput.value = (buyPriceUsd * buyVolume) / sellPriceUsd; + } +} + +function onSubmitClicked() { + let type = "SELL"; + let value = Number(sellValueInput.value); + if (sellValueInput.hasAttribute("readonly")) { + type = "BUY"; + value = Number(buyValueInput.value); + } + const sellTradeableId = parseInt(getSelectedSellOption().value); + const buyTradeableId = parseInt(getSelectedBuyOption().value); + const form = document.getElementById("tradeForm"); + form.elements["type"].value = type; + form.elements["sellTradeableId"].value = sellTradeableId; + form.elements["buyTradeableId"].value = buyTradeableId; + form.elements["value"].value = value; + form.submit(); +} diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index de0c6c1..654f633 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -2,18 +2,53 @@ - - CC - Account - - -

Account

+
+

Account

+

In

+

Total value of

-

Balance

-
    -
  • - - -
  • -
- \ No newline at end of file +

Overview

+ + + + + + + + + + + + + + +
CurrencyBalance
+ +

Recent Transactions

+ + + + + + + + + + + + + + + + + + + +
FromAmount FromToAmount ToTimestamp
+ + Trade + Edit Balances +
\ No newline at end of file diff --git a/src/main/resources/templates/account/edit_balances.html b/src/main/resources/templates/account/edit_balances.html new file mode 100644 index 0000000..04dd210 --- /dev/null +++ b/src/main/resources/templates/account/edit_balances.html @@ -0,0 +1,17 @@ + + +
+

Edit Balances

+ +
+
+ + +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/exchange.html b/src/main/resources/templates/exchange.html new file mode 100644 index 0000000..fb501d5 --- /dev/null +++ b/src/main/resources/templates/exchange.html @@ -0,0 +1,39 @@ + + +
+

+ +

+ Primary tradeable: +

+ +

Supported Tradeable Currencies / Stocks

+ + + + + + + + + + + + + + + + + +
SymbolTypePrice ($)Name
+ +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/exchange/accounts.html b/src/main/resources/templates/exchange/accounts.html new file mode 100644 index 0000000..e93a02f --- /dev/null +++ b/src/main/resources/templates/exchange/accounts.html @@ -0,0 +1,31 @@ + + +
+

Accounts

+ + Add Account + + + + + + + + + + + + + + + + + + + +
NumberNameAdminBalance
Remove
+
diff --git a/src/main/resources/templates/exchange/addAccount.html b/src/main/resources/templates/exchange/addAccount.html new file mode 100644 index 0000000..39427e3 --- /dev/null +++ b/src/main/resources/templates/exchange/addAccount.html @@ -0,0 +1,29 @@ + + +
+

Add Account

+

+ Use this page to add an account to the exchange. +

+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
diff --git a/src/main/resources/templates/exchange/removeAccount.html b/src/main/resources/templates/exchange/removeAccount.html new file mode 100644 index 0000000..2a200e7 --- /dev/null +++ b/src/main/resources/templates/exchange/removeAccount.html @@ -0,0 +1,16 @@ + + +
+

Remove Account

+

+ Are you sure you want to remove this account? +

+
+ Cancel + +
+
diff --git a/src/main/resources/templates/fragment/header.html b/src/main/resources/templates/fragment/header.html new file mode 100644 index 0000000..10c715c --- /dev/null +++ b/src/main/resources/templates/fragment/header.html @@ -0,0 +1,35 @@ + + + + + header + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index d0a1ac4..f3d9d9d 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -2,25 +2,19 @@ - - CC - Home - - +

Welcome!

Your Accounts:

- -
- -
- \ No newline at end of file +
\ No newline at end of file diff --git a/src/main/resources/templates/layout/basic_page.html b/src/main/resources/templates/layout/basic_page.html new file mode 100644 index 0000000..cf25f80 --- /dev/null +++ b/src/main/resources/templates/layout/basic_page.html @@ -0,0 +1,39 @@ + + + + + Coyote Credit + + + + + + + +
+
+
+ +
+
+

Placeholder content.

+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/trade.html b/src/main/resources/templates/trade.html new file mode 100644 index 0000000..ac64229 --- /dev/null +++ b/src/main/resources/templates/trade.html @@ -0,0 +1,64 @@ + + +
+

Trade

+ +
+ + +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + +
+ + +
diff --git a/src/main/resources/templates/user.html b/src/main/resources/templates/user.html new file mode 100644 index 0000000..3bab167 --- /dev/null +++ b/src/main/resources/templates/user.html @@ -0,0 +1,18 @@ + + +
+

My Profile

+ + + + + + + + +
Username
+