Added working first stuff.

This commit is contained in:
Andrew Lalis 2022-02-12 22:26:10 +01:00
parent 5ffe9b3a84
commit 0538334df4
51 changed files with 1479 additions and 149 deletions

View File

@ -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)));
}
}

View File

@ -26,7 +26,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
http http
.authorizeRequests() .authorizeRequests()
.antMatchers( .antMatchers(
"/login", "/login/processing" "/login", "/login/processing", "/static/**"
).permitAll() ).permitAll()
.and() .and()
.authorizeRequests().anyRequest().authenticated() .authorizeRequests().anyRequest().authenticated()

View File

@ -1,14 +1,18 @@
package nl.andrewl.coyotecredit.ctl; package nl.andrewl.coyotecredit.ctl;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.AccountService; import nl.andrewl.coyotecredit.service.AccountService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@Controller @Controller
@RequestMapping(path = "/accounts/{accountId}") @RequestMapping(path = "/accounts/{accountId}")
@ -26,4 +30,18 @@ public class AccountPage {
model.addAttribute("account", data); model.addAttribute("account", data);
return "account"; 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;
}
} }

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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";
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,7 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record AddAccountPayload(
String name,
String username,
String password
) {}

View File

@ -0,0 +1,7 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record BalanceData(
long id,
String symbol,
String amount
) {}

View File

@ -0,0 +1,8 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record ExchangeData(
long id,
String name,
String primaryTradeable
) {
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -0,0 +1,10 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record SimpleAccountData (
long id,
String number,
String name,
boolean admin,
String totalBalance
) {
}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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()
);
}
}

View File

@ -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)
);
}
}

View File

@ -0,0 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record UserData (
long id,
String username
) {}

View File

@ -1,13 +1,17 @@
package nl.andrewl.coyotecredit.dao; package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.model.Account;
import nl.andrewl.coyotecredit.model.Exchange;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface AccountRepository extends JpaRepository<Account, Long> { public interface AccountRepository extends JpaRepository<Account, Long> {
List<Account> findAllByUser(User user); List<Account> findAllByUser(User user);
Optional<Account> findByNumber(String number);
Optional<Account> findByUserAndExchange(User user, Exchange exchange);
} }

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -6,7 +6,9 @@ import lombok.NoArgsConstructor;
import javax.persistence.*; import javax.persistence.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -22,23 +24,72 @@ public class Account {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/**
* The unique account number.
*/
@Column(nullable = false, unique = true) @Column(nullable = false, unique = true)
private String number; private String number;
/**
* The user that this account belongs to.
*/
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
private User user; 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) @ManyToOne(fetch = FetchType.LAZY, optional = false)
private Exchange exchange; 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) @OneToMany(mappedBy = "account", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Balance> balances; private Set<Balance> balances;
public Map<Currency, BigDecimal> getMappedBalances() { public Account(String number, User user, String name, Exchange exchange) {
Map<Currency, BigDecimal> b = new HashMap<>(); 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()) { for (var bal : getBalances()) {
b.put(bal.getCurrency(), bal.getAmount()); b.put(bal.getTradeable(), bal.getAmount());
} }
return b; 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);
}
} }

View File

@ -1,24 +1,43 @@
package nl.andrewl.coyotecredit.model; package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*; import javax.persistence.*;
import java.math.BigDecimal; import java.math.BigDecimal;
/**
* Represents an account's balance for a certain amount of tradeables in their
* exchange.
*/
@Entity @Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "account_balance") @Table(name = "account_balance")
@Getter @Getter
public class Balance { public class Balance {
@Id @EmbeddedId
@GeneratedValue(strategy = GenerationType.IDENTITY) private BalanceId balanceId;
private Long id;
@MapsId("accountId")
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "account_id")
private Account account; private Account account;
@MapsId("tradeableId")
@ManyToOne(optional = false, fetch = FetchType.EAGER) @ManyToOne(optional = false, fetch = FetchType.EAGER)
private Currency currency; @JoinColumn(name = "tradeable_id")
private Tradeable tradeable;
@Column(nullable = false, precision = 24, scale = 10) @Column(nullable = false, precision = 24, scale = 10)
@Setter
private BigDecimal amount; 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;
}
} }

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -1,15 +1,19 @@
package nl.andrewl.coyotecredit.model; package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*; import javax.persistence.*;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* Represents a large collection of users that interact with each other. * Represents a large collection of users that interact with each other.
*/ */
@Entity @Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Getter
public class Exchange { public class Exchange {
@Id @Id
@ -22,15 +26,50 @@ public class Exchange {
@Column(nullable = false) @Column(nullable = false)
private String name; 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<>(); * The set of tradeables that this exchange allows users to interact with.
for (var pair : getCurrencyPairs()) { */
currencies.add(pair.getFromCurrency()); @ManyToMany(fetch = FetchType.LAZY)
currencies.add(pair.getToCurrency()); @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;
} }
return currencies;
@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());
} }
} }

View File

@ -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");
}

View File

@ -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);
}
}

View File

@ -1,6 +1,6 @@
package nl.andrewl.coyotecredit.model; package nl.andrewl.coyotecredit.model;
public enum CurrencyType { public enum TradeableType {
FIAT, FIAT,
CRYPTO, CRYPTO,
STOCK STOCK

View File

@ -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;
}
}

View File

@ -1,18 +1,22 @@
package nl.andrewl.coyotecredit.model; package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*; import javax.persistence.*;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
/** /**
* Represents a basic user in the system. * Represents a basic user in the system.
*/ */
@Entity @Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Getter
public class User implements UserDetails { public class User implements UserDetails {
@Id @Id
@ -31,6 +35,11 @@ public class User implements UserDetails {
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Account> accounts; private Set<Account> accounts;
public User(String username, String passwordHash) {
this.username = username;
this.passwordHash = passwordHash;
this.accounts = new HashSet<>();
}
// USER DETAILS IMPLEMENTATION // USER DETAILS IMPLEMENTATION

View File

@ -1,22 +1,34 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor; 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.AccountRepository;
import nl.andrewl.coyotecredit.dao.TradeableRepository;
import nl.andrewl.coyotecredit.dao.TransactionRepository;
import nl.andrewl.coyotecredit.model.Account; 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 nl.andrewl.coyotecredit.model.User;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList; import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List; import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AccountService { public class AccountService {
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final TransactionRepository transactionRepository;
private final TradeableRepository tradeableRepository;
public static record AccountData ( public static record AccountData (
long id, long id,
@ -27,40 +39,68 @@ public class AccountService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<AccountData> getAccountsOverview(User user) { public List<AccountData> getAccountsOverview(User user) {
return accountRepository.findAllByUser(user).stream() 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(); .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) @Transactional(readOnly = true)
public FullAccountData getAccountData(User user, long accountId) { public FullAccountData getAccountData(User user, long accountId) {
Account account = accountRepository.findById(accountId) Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Account userAccount = accountRepository.findByUserAndExchange(user, account.getExchange())
if (!account.getUser().getId().equals(user.getId())) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!userAccount.isAdmin() && !account.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND); throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} }
FullAccountData d = new FullAccountData(); List<TransactionData> transactionData = transactionRepository.findAllByAccountNumberOrderByTimestampDesc(account.getNumber(), PageRequest.of(0, 5))
d.id = account.getId(); .map(TransactionData::new)
d.number = account.getNumber(); .stream().toList();
d.exchangeName = account.getExchange().getName(); return new FullAccountData(
List<BalanceData> balanceData = new ArrayList<>(); account.getId(),
for (var bal : account.getBalances()) { account.getNumber(),
balanceData.add(new BalanceData(bal.getCurrency().getIdentifier(), bal.getAmount().toPlainString())); 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
);
} }
d.balances = balanceData;
return d; @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);
}
}
}
accountRepository.save(account);
} }
} }

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -8,7 +8,7 @@ public class AccountNumberUtils {
StringBuilder sb = new StringBuilder(19); StringBuilder sb = new StringBuilder(19);
Random rand = new SecureRandom(); Random rand = new SecureRandom();
for (int i = 0; i < 16; i++) { 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)); sb.append(rand.nextInt(0, 10));
} }
return sb.toString(); return sb.toString();

View File

View File

@ -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();
}

View File

@ -2,18 +2,53 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Account', content=~{::#content})}"
> >
<head>
<title>CC - Account</title>
</head>
<body> <div id="content" class="container">
<h1>Account <span th:text="${account.number}"></span></h1> <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> <h3>Overview</h3>
<ul>
<li th:each="balance : ${account.balances}"> <table class="table">
<span th:text="${balance.currencyIdentifier}"></span> - <span th:text="${balance.amount}"></span> <thead>
</li> <tr>
</ul> <th>Currency</th>
</body> <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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -2,25 +2,19 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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> <h1>Welcome!</h1>
<h3>Your Accounts:</h3> <h3>Your Accounts:</h3>
<ul> <ul>
<li th:each="account : ${accounts}"> <li th:each="account : ${accounts}">
<a th:href="@{/accounts/{id}(id=${account.id()})}"> <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> <span th:text="${account.exchangeName()}"></span>
</a> </a>
</li> </li>
</ul> </ul>
</div>
<form th:action="@{/logout}" th:method="post">
<input type="submit" value="Logout">
</form>
</body>

View File

@ -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>

View File

@ -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>

View File

@ -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>