Added working first stuff.
This commit is contained in:
parent
5ffe9b3a84
commit
0538334df4
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
|||
http
|
||||
.authorizeRequests()
|
||||
.antMatchers(
|
||||
"/login", "/login/processing"
|
||||
"/login", "/login/processing", "/static/**"
|
||||
).permitAll()
|
||||
.and()
|
||||
.authorizeRequests().anyRequest().authenticated()
|
||||
|
|
|
@ -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<String, String> paramMap) {
|
||||
accountService.editBalances(accountId, user, paramMap);
|
||||
return "redirect:/accounts/" + accountId;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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<Long, String> getCurrentTradeables(@PathVariable long exchangeId) {
|
||||
return exchangeService.getCurrentTradeables(exchangeId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record AddAccountPayload(
|
||||
String name,
|
||||
String username,
|
||||
String password
|
||||
) {}
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record BalanceData(
|
||||
long id,
|
||||
String symbol,
|
||||
String amount
|
||||
) {}
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record ExchangeData(
|
||||
long id,
|
||||
String name,
|
||||
String primaryTradeable
|
||||
) {
|
||||
}
|
|
@ -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<BalanceData> balances,
|
||||
String totalBalance,
|
||||
List<TransactionData> recentTransactions
|
||||
) {
|
||||
}
|
|
@ -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<TradeableData> supportedTradeables,
|
||||
// Account info
|
||||
boolean accountAdmin
|
||||
) {
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record SimpleAccountData (
|
||||
long id,
|
||||
String number,
|
||||
String name,
|
||||
boolean admin,
|
||||
String totalBalance
|
||||
) {
|
||||
}
|
|
@ -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<TradeableData> tradeablesToSell,
|
||||
Map<String, BigDecimal> accountBalances,
|
||||
List<TradeableData> tradeablesToBuy
|
||||
) {}
|
|
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
public record TradePayload(
|
||||
String type, // SELL or BUY
|
||||
long sellTradeableId,
|
||||
long buyTradeableId,
|
||||
String value
|
||||
) {}
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record UserData (
|
||||
long id,
|
||||
String username
|
||||
) {}
|
|
@ -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<Account, Long> {
|
||||
List<Account> findAllByUser(User user);
|
||||
Optional<Account> findByNumber(String number);
|
||||
Optional<Account> findByUserAndExchange(User user, Exchange exchange);
|
||||
}
|
||||
|
|
|
@ -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<Exchange, Long> {
|
||||
}
|
|
@ -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<Tradeable, Long> {
|
||||
}
|
|
@ -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<Transaction, Long> {
|
||||
Page<Transaction> findAllByAccountNumberOrderByTimestampDesc(String accountNumber, Pageable pageable);
|
||||
}
|
|
@ -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<Balance> balances;
|
||||
|
||||
public Map<Currency, BigDecimal> getMappedBalances() {
|
||||
Map<Currency, BigDecimal> 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<Tradeable, BigDecimal> getMappedBalances() {
|
||||
Map<Tradeable, BigDecimal> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<ExchangePair> currencyPairs;
|
||||
/**
|
||||
* The primary tradeable that's used by this exchange.
|
||||
*/
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
private Tradeable primaryTradeable;
|
||||
|
||||
public Set<Currency> getSupportedCurrencies() {
|
||||
Set<Currency> 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<Tradeable> 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<CustomTradeable> customTradeables;
|
||||
|
||||
/**
|
||||
* The set of accounts that are registered with this exchange.
|
||||
*/
|
||||
@OneToMany(mappedBy = "exchange", fetch = FetchType.LAZY)
|
||||
private Set<Account> accounts;
|
||||
|
||||
public Set<Tradeable> getAllTradeables() {
|
||||
Set<Tradeable> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package nl.andrewl.coyotecredit.model;
|
||||
|
||||
public enum CurrencyType {
|
||||
public enum TradeableType {
|
||||
FIAT,
|
||||
CRYPTO,
|
||||
STOCK
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<Account> accounts;
|
||||
|
||||
public User(String username, String passwordHash) {
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.accounts = new HashSet<>();
|
||||
}
|
||||
|
||||
// USER DETAILS IMPLEMENTATION
|
||||
|
||||
|
|
|
@ -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<AccountData> 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<BalanceData> 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> balanceData = new ArrayList<>();
|
||||
for (var bal : account.getBalances()) {
|
||||
balanceData.add(new BalanceData(bal.getCurrency().getIdentifier(), bal.getAmount().toPlainString()));
|
||||
List<TransactionData> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SimpleAccountData> 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<Long, String> getCurrentTradeables(long exchangeId) {
|
||||
Exchange e = exchangeRepository.findById(exchangeId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
Map<Long, String> 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);
|
||||
}
|
||||
}
|
|
@ -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<TradeableData> sellList = new ArrayList<>();
|
||||
Map<String, BigDecimal> 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<TradeableData> 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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -2,18 +2,53 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Account', content=~{::#content})}"
|
||||
>
|
||||
<head>
|
||||
<title>CC - Account</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Account <span th:text="${account.number}"></span></h1>
|
||||
<div id="content" class="container">
|
||||
<h1>Account <span th:text="${account.number()}"></span></h1>
|
||||
<p>In <a th:href="@{/exchanges/{id}(id=${account.exchange().id()})}" th:text="${account.exchange().name()}"></a></p>
|
||||
<p>Total value of <span th:text="${account.totalBalance() + ' ' + account.exchange().primaryTradeable()}"></span></p>
|
||||
|
||||
<h3>Balance</h3>
|
||||
<ul>
|
||||
<li th:each="balance : ${account.balances}">
|
||||
<span th:text="${balance.currencyIdentifier}"></span> - <span th:text="${balance.amount}"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
<h3>Overview</h3>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Currency</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="bal : ${account.balances()}">
|
||||
<td th:text="${bal.symbol()}"></td>
|
||||
<td th:text="${bal.amount()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Recent Transactions</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<th>Amount From</th>
|
||||
<th>To</th>
|
||||
<th>Amount To</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="tx : ${account.recentTransactions()}">
|
||||
<td th:text="${tx.from().name()}"></td>
|
||||
<td th:text="${tx.fromAmount()}"></td>
|
||||
<td th:text="${tx.to().name()}"></td>
|
||||
<td th:text="${tx.toAmount()}"></td>
|
||||
<td th:text="${tx.timestamp()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a class="btn btn-success" th:href="@{/trade/{account}(account=${account.id()})}">Trade</a>
|
||||
<a class="btn btn-primary" th:if="${account.userAdmin()}" th:href="@{/accounts/{aId}/editBalances(aId=${account.id()})}">Edit Balances</a>
|
||||
</div>
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Edit Balances</h1>
|
||||
|
||||
<form th:action="@{/accounts/{aId}/editBalances(aId=${accountId})}" th:method="post">
|
||||
<div class="mb-3" th:each="bal, iter : ${account.balances()}">
|
||||
<label th:for="${'tradeable-' + bal.id()}" class="form-label" th:text="${bal.symbol()}"></label>
|
||||
<input type="number" min="0" th:value="${bal.amount()}" th:name="${'tradeable-' + bal.id()}" class="form-control"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Submit</button>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Exchange', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1 th:text="${exchange.name()}"></h1>
|
||||
|
||||
<p>
|
||||
Primary tradeable: <span th:text="${exchange.primaryTradeable().name()}"></span>
|
||||
</p>
|
||||
|
||||
<h3>Supported Tradeable Currencies / Stocks</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Type</th>
|
||||
<th>Price ($)</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="tradeable : ${exchange.supportedTradeables()}">
|
||||
<td th:text="${tradeable.symbol()}"></td>
|
||||
<td th:text="${tradeable.type()}"></td>
|
||||
<td th:text="${tradeable.marketPriceUsd()}"></td>
|
||||
<td th:text="${tradeable.name()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div th:if="${exchange.accountAdmin()}">
|
||||
<div>
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">View Accounts</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Exchange Accounts', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Accounts</h1>
|
||||
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}">Add Account</a>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Number</th>
|
||||
<th>Name</th>
|
||||
<th>Admin</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="account : ${accounts}">
|
||||
<td><a th:href="@{/accounts/{id}(id=${account.id()})}" th:text="${account.number()}"></a></td>
|
||||
<td th:text="${account.name()}"></td>
|
||||
<td th:text="${account.admin()}"></td>
|
||||
<td th:text="${account.totalBalance()}"></td>
|
||||
<td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Add Account</h1>
|
||||
<p>
|
||||
Use this page to add an account to the exchange.
|
||||
</p>
|
||||
|
||||
<form th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}" th:method="post">
|
||||
<div class="mb-3">
|
||||
<label for="nameInput" class="form-label">Name</label>
|
||||
<input id="nameInput" type="text" class="form-control" name="name"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="usernameInput" class="form-label">Username</label>
|
||||
<input id="usernameInput" type="text" class="form-control" name="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="passwordInput" class="form-label">Password</label>
|
||||
<input id="passwordInput" type="password" class="form-control" name="password"/>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Remove Account</h1>
|
||||
<p>
|
||||
Are you sure you want to remove this account?
|
||||
</p>
|
||||
<form th:action="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${accountId})}" th:method="post">
|
||||
<a class="btn btn-secondary" th:href="@{/exchanges/{eId}/accounts(eId=${exchangeId})}">Cancel</a>
|
||||
<button class="btn btn-danger" type="submit">Remove</button>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>header</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav th:fragment="header" class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">Coyote Credit</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/}">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">My Profile</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="d-flex" th:action="@{/logout}" th:method="post">
|
||||
<button class="btn btn-outline-success" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</body>
|
|
@ -2,25 +2,19 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Home', content=~{::#content})}"
|
||||
>
|
||||
<head>
|
||||
<title>CC - Home</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="content" class="container">
|
||||
<h1>Welcome!</h1>
|
||||
|
||||
<h3>Your Accounts:</h3>
|
||||
<ul>
|
||||
<li th:each="account : ${accounts}">
|
||||
<a th:href="@{/accounts/{id}(id=${account.id()})}">
|
||||
<span th:text="${account.accountNumber()}"></span>@
|
||||
<span th:text="${account.accountNumber()}"></span> @
|
||||
<span th:text="${account.exchangeName()}"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form th:action="@{/logout}" th:method="post">
|
||||
<input type="submit" value="Logout">
|
||||
</form>
|
||||
</body>
|
||||
</div>
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
th:fragment="layout (title, content)"
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title th:text="${'Coyote Credit - ' + title}">Coyote Credit</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.css"/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header sec:authorize="isAuthenticated()">
|
||||
<div th:replace="~{fragment/header :: header}"></div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div th:replace="${content}" class="row">
|
||||
<p>Placeholder content.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,64 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Trade', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Trade</h1>
|
||||
|
||||
<form id="tradeForm" th:action="@{/trade/{account}(account=${data.accountId()})}" method="post">
|
||||
<input type="hidden" id="exchangeIdInput" th:value="${data.exchangeId()}"/>
|
||||
<input type="hidden" id="accountIdInput" th:value="${data.accountId()}"/>
|
||||
<div class="mb-3">
|
||||
<label for="sellTradeableSelect" class="form-label">Tradeable to Sell</label>
|
||||
<select id="sellTradeableSelect" class="form-select">
|
||||
<option selected hidden>Choose something to sell</option>
|
||||
<option
|
||||
th:each="t : ${data.tradeablesToSell()}"
|
||||
th:value="${t.id()}"
|
||||
th:text="${t.name() + ' (' + t.symbol() + ')'}"
|
||||
th:data-priceusd="${t.marketPriceUsd()}"
|
||||
th:data-type="${t.type()}"
|
||||
th:data-balance="${data.accountBalances().get(t.symbol()).toPlainString()}"
|
||||
th:disabled="${data.accountBalances().get(t.symbol()).signum() == 0 ? true : false}"
|
||||
></option>
|
||||
</select>
|
||||
<div id="sellTradeableSelectText" class="form-text" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="sellValueInput" class="form-label">Value to Sell</label>
|
||||
<input type="number" class="form-control" id="sellValueInput"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="buyTradeableSelect" class="form-label">Tradeable to Buy</label>
|
||||
<select id="buyTradeableSelect" class="form-select">
|
||||
<option value="" selected disabled hidden>Choose something to buy</option>
|
||||
<option
|
||||
th:each="t : ${data.tradeablesToBuy()}"
|
||||
th:value="${t.id()}"
|
||||
th:text="${t.name() + ' (' + t.symbol() + ')'}"
|
||||
th:data-priceusd="${t.marketPriceUsd()}"
|
||||
th:data-type="${t.type()}"
|
||||
></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="buyValueInput" class="form-label">Value to Buy</label>
|
||||
<input type="number" class="form-control" id="buyValueInput"/>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
|
||||
<input type="hidden" name="type"/>
|
||||
<input type="hidden" name="sellTradeableId"/>
|
||||
<input type="hidden" name="buyTradeableId"/>
|
||||
<input type="hidden" name="value"/>
|
||||
|
||||
<button id="submitButton" type="button" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
|
||||
<script src="/static/js/trade.js"></script>
|
||||
</div>
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='My Profile', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>My Profile</h1>
|
||||
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Username</th>
|
||||
<td th:text="${user.username()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
Loading…
Reference in New Issue