Added transfer, more improvements.

This commit is contained in:
Andrew Lalis 2022-02-13 13:27:30 +01:00
parent 0538334df4
commit 6183ccbea9
49 changed files with 809 additions and 134 deletions

2
.gitignore vendored
View File

@ -31,3 +31,5 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
/config/

View File

@ -0,0 +1,9 @@
package nl.andrewl.coyotecredit.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}

View File

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

View File

@ -1,7 +1,7 @@
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.ctl.dto.TransferPayload;
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.http.HttpStatus;
@ -12,8 +12,6 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@Controller @Controller
@RequestMapping(path = "/accounts/{accountId}") @RequestMapping(path = "/accounts/{accountId}")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -44,4 +42,16 @@ public class AccountPage {
accountService.editBalances(accountId, user, paramMap); accountService.editBalances(accountId, user, paramMap);
return "redirect:/accounts/" + accountId; return "redirect:/accounts/" + accountId;
} }
@GetMapping(path = "/transfer")
public String getTransferPage(Model model, @PathVariable long accountId, @AuthenticationPrincipal User user) {
model.addAttribute("balances", accountService.getTransferData(accountId, user));
return "account/transfer";
}
@PostMapping(path = "/transfer")
public String postTransfer(@PathVariable long accountId, @ModelAttribute TransferPayload payload, @AuthenticationPrincipal User user) {
accountService.transfer(accountId, user, payload);
return "redirect:/accounts/" + accountId;
}
} }

View File

@ -10,42 +10,48 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@Controller @Controller
@RequestMapping(path = "/exchanges/{exchangeId}") @RequestMapping(path = "/exchanges")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ExchangePage { public class ExchangeController {
private final ExchangeService exchangeService; private final ExchangeService exchangeService;
@GetMapping @GetMapping
public String get(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) { public String getExchanges(Model model, @AuthenticationPrincipal User user) {
model.addAttribute("exchange", exchangeService.getData(exchangeId, user)); model.addAttribute("exchangeData", exchangeService.getExchanges(user));
return "exchange"; return "exchange/exchanges";
} }
@GetMapping(path = "/accounts") @GetMapping(path = "/{exchangeId}")
public String get(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) {
model.addAttribute("exchange", exchangeService.getData(exchangeId, user));
return "exchange/exchange";
}
@GetMapping(path = "/{exchangeId}/accounts")
public String getAccountsPage(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) { public String getAccountsPage(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) {
model.addAttribute("accounts", exchangeService.getAccounts(exchangeId, user)); model.addAttribute("accounts", exchangeService.getAccounts(exchangeId, user));
return "exchange/accounts"; return "exchange/accounts";
} }
@GetMapping(path = "/addAccount") @GetMapping(path = "/{exchangeId}/addAccount")
public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) { public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
exchangeService.ensureAdminAccount(exchangeId, user); exchangeService.ensureAdminAccount(exchangeId, user);
return "exchange/addAccount"; return "exchange/addAccount";
} }
@PostMapping(path = "/addAccount") @PostMapping(path = "/{exchangeId}/addAccount")
public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) { public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) {
long accountId = exchangeService.addAccount(exchangeId, user, payload); long accountId = exchangeService.addAccount(exchangeId, user, payload);
return "redirect:/accounts/" + accountId; return "redirect:/accounts/" + accountId;
} }
@GetMapping(path = "/removeAccount/{accountId}") @GetMapping(path = "/{exchangeId}/removeAccount/{accountId}")
public String getRemoveAccountPage(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) { public String getRemoveAccountPage(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) {
exchangeService.ensureAdminAccount(exchangeId, user); exchangeService.ensureAdminAccount(exchangeId, user);
return "exchange/removeAccount"; return "exchange/removeAccount";
} }
@PostMapping(path = "/removeAccount/{accountId}") @PostMapping(path = "/{exchangeId}/removeAccount/{accountId}")
public String postRemoveAccount(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) { public String postRemoveAccount(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) {
exchangeService.removeAccount(exchangeId, accountId, user); exchangeService.removeAccount(exchangeId, accountId, user);
return "redirect:/exchanges/" + exchangeId + "/accounts"; return "redirect:/exchanges/" + exchangeId + "/accounts";

View File

@ -1,14 +0,0 @@
package nl.andrewl.coyotecredit.ctl;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(path = "/login")
public class LoginPage {
@GetMapping
public String get() {
return "login";
}
}

View File

@ -0,0 +1,38 @@
package nl.andrewl.coyotecredit.ctl;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
import nl.andrewl.coyotecredit.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequiredArgsConstructor
public class PublicPageController {
private final UserService userService;
@GetMapping(path = "/login")
public String getLoginPage() {
return "public/login";
}
@GetMapping(path = "/register")
public String getRegisterPage() {
return "public/register";
}
@PostMapping(path = "/register")
public String postRegister(@ModelAttribute RegisterPayload payload) {
userService.registerUser(payload);
return "redirect:/login";
}
@GetMapping(path = "/activate")
public String activateAccount(@RequestParam(name = "token") String token) {
userService.activateUser(token);
return "redirect:/login";
}
}

View File

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

View File

@ -3,5 +3,6 @@ package nl.andrewl.coyotecredit.ctl.dto;
public record BalanceData( public record BalanceData(
long id, long id,
String symbol, String symbol,
String type,
String amount String amount
) {} ) {}

View File

@ -0,0 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record ExchangeAccountData(
ExchangeData exchange,
SimpleAccountData account
) {}

View File

@ -10,7 +10,7 @@ public record FullExchangeData (
String name, String name,
TradeableData primaryTradeable, TradeableData primaryTradeable,
List<TradeableData> supportedTradeables, List<TradeableData> supportedTradeables,
// Account info // Account info that's needed for determining if it's possible to do some actions.
boolean accountAdmin boolean accountAdmin,
) { long accountId
} ) {}

View File

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

View File

@ -2,20 +2,26 @@ package nl.andrewl.coyotecredit.ctl.dto;
import nl.andrewl.coyotecredit.model.Tradeable; import nl.andrewl.coyotecredit.model.Tradeable;
import java.text.DecimalFormat;
public record TradeableData( public record TradeableData(
long id, long id,
String symbol, String symbol,
String type, String type,
String marketPriceUsd, String marketPriceUsd,
String formattedPriceUsd,
String name, String name,
String description String description
) { ) {
public static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,##0.00");
public TradeableData(Tradeable t) { public TradeableData(Tradeable t) {
this( this(
t.getId(), t.getId(),
t.getSymbol(), t.getSymbol(),
t.getType().name(), t.getType().name(),
t.getMarketPriceUsd().toPlainString(), t.getMarketPriceUsd().toPlainString(),
DECIMAL_FORMAT.format(t.getMarketPriceUsd()),
t.getName(), t.getName(),
t.getDescription() t.getDescription()
); );

View File

@ -0,0 +1,8 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record TransferPayload (
String recipientNumber,
String amount,
long tradeableId,
String message
) {}

View File

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

View File

@ -4,6 +4,9 @@ import nl.andrewl.coyotecredit.model.Tradeable;
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;
@Repository @Repository
public interface TradeableRepository extends JpaRepository<Tradeable, Long> { public interface TradeableRepository extends JpaRepository<Tradeable, Long> {
List<Tradeable> findAllByExchangeNull();
} }

View File

@ -0,0 +1,18 @@
package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.Transfer;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface TransferRepository extends JpaRepository<Transfer, Long> {
@Query(
"SELECT t FROM Transfer t " +
"WHERE t.senderNumber = :accountNumber OR t.recipientNumber = :accountNumber " +
"ORDER BY t.timestamp DESC"
)
Page<Transfer> findAllForAccount(String accountNumber, Pageable pageable);
}

View File

@ -0,0 +1,12 @@
package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.UserActivationToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface UserActivationTokenRepository extends JpaRepository<UserActivationToken, String> {
void deleteAllByExpiresAtBefore(LocalDateTime time);
}

View File

@ -9,4 +9,5 @@ import java.util.Optional;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
} }

View File

@ -1,20 +0,0 @@
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

@ -47,7 +47,7 @@ public class Exchange {
* The set of custom tradeables created specifically for use in this exchange. * The set of custom tradeables created specifically for use in this exchange.
*/ */
@OneToMany(mappedBy = "exchange", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "exchange", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private Set<CustomTradeable> customTradeables; private Set<Tradeable> customTradeables;
/** /**
* The set of accounts that are registered with this exchange. * The set of accounts that are registered with this exchange.

View File

@ -3,6 +3,7 @@ package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*; import javax.persistence.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -29,6 +30,7 @@ public class Tradeable {
private TradeableType type; private TradeableType type;
@Column(nullable = false, precision = 24, scale = 10) @Column(nullable = false, precision = 24, scale = 10)
@Setter
private BigDecimal marketPriceUsd = new BigDecimal(1); private BigDecimal marketPriceUsd = new BigDecimal(1);
@Column(nullable = false) @Column(nullable = false)
@ -37,12 +39,19 @@ public class Tradeable {
@Column @Column
private String description; private String description;
public Tradeable(String symbol, TradeableType type, String name, String description, BigDecimal marketPriceUsd) { /**
* The exchange that this tradeable belongs to, if any.
*/
@ManyToOne(fetch = FetchType.LAZY)
private Exchange exchange;
public Tradeable(String symbol, TradeableType type, String name, String description, BigDecimal marketPriceUsd, Exchange exchange) {
this.symbol = symbol; this.symbol = symbol;
this.type = type; this.type = type;
this.name = name; this.name = name;
this.description = description; this.description = description;
this.marketPriceUsd = marketPriceUsd; this.marketPriceUsd = marketPriceUsd;
this.exchange = exchange;
} }
@Override @Override

View File

@ -0,0 +1,49 @@
package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/**
* Represents a transfer of funds from one account to another.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Transfer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, updatable = false)
private LocalDateTime timestamp;
@Column(nullable = false)
private String senderNumber;
@Column(nullable = false)
private String recipientNumber;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
private Tradeable tradeable;
@Column(nullable = false, precision = 24, scale = 10)
private BigDecimal amount;
@Column(length = 1024)
private String message;
public Transfer(String sender, String recipient, Tradeable tradeable, BigDecimal amount, String message) {
this.senderNumber = sender;
this.recipientNumber = recipient;
this.tradeable = tradeable;
this.amount = amount;
this.message = message;
this.timestamp = LocalDateTime.now(ZoneOffset.UTC);
}
}

View File

@ -3,10 +3,13 @@ package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter;
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.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
@ -29,15 +32,27 @@ public class User implements UserDetails {
@Column(nullable = false) @Column(nullable = false)
private String passwordHash; private String passwordHash;
@Column(nullable = false)
private String email;
@Column(nullable = false)
@Setter
private boolean activated = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
/** /**
* The set of accounts this user has. * The set of accounts this user has.
*/ */
@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) { public User(String username, String passwordHash, String email) {
this.username = username; this.username = username;
this.passwordHash = passwordHash; this.passwordHash = passwordHash;
this.email = email;
this.createdAt = LocalDateTime.now(ZoneOffset.UTC);
this.accounts = new HashSet<>(); this.accounts = new HashSet<>();
} }
@ -75,6 +90,6 @@ public class User implements UserDetails {
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return true; return this.activated;
} }
} }

View File

@ -0,0 +1,32 @@
package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserActivationToken {
@Id
@Column(nullable = false, unique = true, updatable = false)
private String token;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
private User user;
/**
* The time at which this token expires, in UTC.
*/
@Column(nullable = false)
private LocalDateTime expiresAt;
public UserActivationToken(String token, User user, LocalDateTime expiresAt) {
this.token = token;
this.user = user;
this.expiresAt = expiresAt;
}
}

View File

@ -1,17 +1,12 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.BalanceData; import nl.andrewl.coyotecredit.ctl.dto.*;
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.TradeableRepository;
import nl.andrewl.coyotecredit.dao.TransactionRepository; import nl.andrewl.coyotecredit.dao.TransactionRepository;
import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.dao.TransferRepository;
import nl.andrewl.coyotecredit.model.Balance; import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.User;
import org.springframework.data.domain.PageRequest; 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;
@ -29,6 +24,61 @@ public class AccountService {
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final TradeableRepository tradeableRepository; private final TradeableRepository tradeableRepository;
private final TransferRepository transferRepository;
@Transactional(readOnly = true)
public List<BalanceData> getTransferData(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);
}
return account.getBalances().stream()
.filter(b -> b.getAmount().compareTo(BigDecimal.ZERO) > 0)
.map(b -> new BalanceData(
b.getTradeable().getId(),
b.getTradeable().getSymbol(),
b.getTradeable().getType().name(),
b.getAmount().toPlainString()
))
.sorted(Comparator.comparing(BalanceData::symbol))
.toList();
}
@Transactional
public void transfer(long accountId, User user, TransferPayload payload) {
Account sender = accountRepository.findById(accountId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!sender.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
Account recipient = accountRepository.findByNumber(payload.recipientNumber())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown recipient."));
Tradeable tradeable = tradeableRepository.findById(payload.tradeableId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown tradeable asset."));
BigDecimal amount = new BigDecimal(payload.amount());
if (amount.compareTo(BigDecimal.ZERO) <= 0) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid amount. Should be positive.");
Balance senderBalance = sender.getBalanceForTradeable(tradeable);
if (senderBalance == null || senderBalance.getAmount().compareTo(amount) < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Not enough funds to transfer.");
}
Balance recipientBalance = recipient.getBalanceForTradeable(tradeable);
if (recipientBalance == null) {
recipientBalance = new Balance(recipient, tradeable, BigDecimal.ZERO);
recipient.getBalances().add(recipientBalance);
}
senderBalance.setAmount(senderBalance.getAmount().subtract(amount));
recipientBalance.setAmount(recipientBalance.getAmount().add(amount));
accountRepository.save(sender);
accountRepository.save(recipient);
transferRepository.save(new Transfer(
sender.getNumber(),
recipient.getNumber(),
tradeable,
amount,
payload.message()
));
}
public static record AccountData ( public static record AccountData (
long id, long id,
@ -71,10 +121,15 @@ public class AccountService {
account.getExchange().getPrimaryTradeable().getSymbol() account.getExchange().getPrimaryTradeable().getSymbol()
), ),
account.getBalances().stream() account.getBalances().stream()
.map(b -> new BalanceData(b.getTradeable().getId(), b.getTradeable().getSymbol(), b.getAmount().toPlainString())) .map(b -> new BalanceData(
b.getTradeable().getId(),
b.getTradeable().getSymbol(),
b.getTradeable().getType().name(),
b.getAmount().toPlainString()
))
.sorted(Comparator.comparing(BalanceData::symbol)) .sorted(Comparator.comparing(BalanceData::symbol))
.toList(), .toList(),
account.getTotalBalance().toPlainString(), TradeableData.DECIMAL_FORMAT.format(account.getTotalBalance()),
transactionData transactionData
); );
} }

View File

@ -45,7 +45,8 @@ public class ExchangeService {
.map(TradeableData::new) .map(TradeableData::new)
.sorted(Comparator.comparing(TradeableData::symbol)) .sorted(Comparator.comparing(TradeableData::symbol))
.toList(), .toList(),
account.isAdmin() account.isAdmin(),
account.getId()
); );
} }
@ -91,7 +92,7 @@ public class ExchangeService {
if (!account.isAdmin()) { if (!account.isAdmin()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND); throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} }
User u = userRepository.save(new User(payload.username(), passwordEncoder.encode(payload.password()))); User u = userRepository.save(new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email()));
Account a = accountRepository.save(new Account( Account a = accountRepository.save(new Account(
AccountNumberUtils.generate(), AccountNumberUtils.generate(),
u, u,
@ -188,4 +189,15 @@ public class ExchangeService {
Transaction tx = new Transaction(account.getNumber(), exchange, from, fromValue, to, toValue, LocalDateTime.now(ZoneOffset.UTC)); Transaction tx = new Transaction(account.getNumber(), exchange, from, fromValue, to, toValue, LocalDateTime.now(ZoneOffset.UTC));
transactionRepository.save(tx); transactionRepository.save(tx);
} }
@Transactional(readOnly = true)
public List<ExchangeAccountData> getExchanges(User user) {
return accountRepository.findAllByUser(user).stream()
.map(a -> new ExchangeAccountData(
new ExchangeData(a.getExchange().getId(), a.getExchange().getName(), a.getExchange().getPrimaryTradeable().getSymbol()),
new SimpleAccountData(a.getId(), a.getNumber(), a.getName(), a.isAdmin(), TradeableData.DECIMAL_FORMAT.format(a.getTotalBalance()))
))
.sorted(Comparator.comparing(d -> d.exchange().name()))
.toList();
}
} }

View File

@ -0,0 +1,155 @@
package nl.andrewl.coyotecredit.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.coyotecredit.dao.TradeableRepository;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.TradeableType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
@Slf4j
public class TradeableUpdateService {
private final TradeableRepository tradeableRepository;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
@Value("${coyote-credit.polygon.api-key}")
private String polygonApiKey;
private static final int POLYGON_API_TIMEOUT = 15;
@Scheduled(cron = "@midnight")
public void updatePublicTradeables() {
List<Tradeable> publicTradeables = tradeableRepository.findAllByExchangeNull();
long delay = 5;
for (var tradeable : publicTradeables) {
// Special case of ignoring USD as the universal transfer currency.
if (tradeable.getSymbol().equals("USD")) continue;
executorService.schedule(() -> updateTradeable(tradeable), delay, TimeUnit.SECONDS);
delay += POLYGON_API_TIMEOUT;
}
}
private void updateTradeable(Tradeable tradeable) {
BigDecimal updatedValue = null;
if (tradeable.getType().equals(TradeableType.STOCK)) {
updatedValue = fetchStockClosePrice(tradeable.getSymbol());
} else if (tradeable.getType().equals(TradeableType.CRYPTO)) {
updatedValue = fetchCryptoClosePrice(tradeable.getSymbol());
} else if (tradeable.getType().equals(TradeableType.FIAT)) {
updatedValue = fetchForexClosePrice(tradeable.getSymbol());
}
if (updatedValue != null) {
log.info(
"Updating market price for tradeable {} ({}, {}) from {} to {}.",
tradeable.getId(),
tradeable.getSymbol(),
tradeable.getName(),
tradeable.getMarketPriceUsd().toPlainString(),
updatedValue.toPlainString()
);
tradeable.setMarketPriceUsd(updatedValue);
tradeableRepository.save(tradeable);
}
}
private BigDecimal fetchStockClosePrice(String symbol) {
String url = String.format("https://api.polygon.io/v2/aggs/ticker/%s/prev?adjusted=true", symbol);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.GET()
.header("Authorization", "Bearer " + polygonApiKey)
.build();
try {
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
ObjectNode data = objectMapper.readValue(response.body(), ObjectNode.class);
JsonNode resultsCount = data.get("resultsCount");
if (resultsCount != null && resultsCount.isIntegralNumber() && resultsCount.asInt() > 0) {
String closePriceText = data.withArray("results").get(0).get("c").asText();
return new BigDecimal(closePriceText);
} else {
throw new IOException("No results were returned.");
}
} else {
throw new IOException("Request returned a non-200 status: " + response.statusCode());
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
return null;
}
}
private BigDecimal fetchCryptoClosePrice(String symbol) {
String date = LocalDate.now(ZoneOffset.UTC).minusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE);
String url = String.format("https://api.polygon.io/v1/open-close/crypto/%s/USD/%s?adjusted=true", symbol, date);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.GET()
.header("Authorization", "Bearer " + polygonApiKey)
.build();
try {
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
ObjectNode data = objectMapper.readValue(response.body(), ObjectNode.class);
JsonNode close = data.get("close");
if (close != null && close.isNumber()) {
return new BigDecimal(close.asText());
} else {
throw new IOException("No data available.");
}
} else {
throw new IOException("Request returned a non-200 status: " + response.statusCode());
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
return null;
}
}
private BigDecimal fetchForexClosePrice(String symbol) {
String url = String.format("https://api.polygon.io/v2/aggs/ticker/C:%sUSD/prev?adjusted=true", symbol);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.GET()
.header("Authorization", "Bearer " + polygonApiKey)
.build();
try {
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
ObjectNode data = objectMapper.readValue(response.body(), ObjectNode.class);
JsonNode resultsCount = data.get("resultsCount");
if (resultsCount != null && resultsCount.isIntegralNumber() && resultsCount.asInt() > 0) {
String closePriceText = data.withArray("results").get(0).get("c").asText();
return new BigDecimal(closePriceText);
} else {
throw new IOException("No results were returned.");
}
} else {
throw new IOException("Request returned a non-200 status: " + response.statusCode());
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
return null;
}
}
}

View File

@ -1,20 +1,40 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
import nl.andrewl.coyotecredit.ctl.dto.UserData; import nl.andrewl.coyotecredit.ctl.dto.UserData;
import nl.andrewl.coyotecredit.dao.UserActivationTokenRepository;
import nl.andrewl.coyotecredit.dao.UserRepository; import nl.andrewl.coyotecredit.dao.UserRepository;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.model.UserActivationToken;
import nl.andrewl.coyotecredit.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.password.PasswordEncoder;
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.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserService { public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserActivationTokenRepository activationTokenRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
@Transactional @Value("${coyote-credit.base-url}")
private String baseUrl;
@Transactional(readOnly = true)
public UserData getUser(long userId, User requestingUser) { public UserData getUser(long userId, User requestingUser) {
User user; User user;
if (requestingUser.getId().equals(userId)) { if (requestingUser.getId().equals(userId)) {
@ -22,6 +42,62 @@ public class UserService {
} else { } else {
user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
} }
return new UserData(user.getId(), user.getUsername()); return new UserData(user.getId(), user.getUsername(), user.getEmail());
}
@Transactional
public void registerUser(RegisterPayload payload) {
if (userRepository.existsByUsername(payload.username())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is already taken.");
}
User user = new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email());
user = userRepository.save(user);
String token = StringUtils.random(64);
LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24);
UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt);
activationTokenRepository.save(activationToken);
try {
sendActivationEmail(activationToken);
} catch (MessagingException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not send activation email.");
}
}
@Transactional
public void activateUser(String tokenString) {
UserActivationToken token = activationTokenRepository.findById(tokenString)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid activation code."));
if (token.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Activation code is expired.");
}
token.getUser().setActivated(true);
activationTokenRepository.delete(token);
userRepository.save(token.getUser());
}
private void sendActivationEmail(UserActivationToken token) throws MessagingException {
MimeMessage msg = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(msg);
helper.setFrom("Coyote Credit <noreply@coyote-credit.com>");
helper.setTo(token.getUser().getEmail());
helper.setSubject("Activate Your Account");
String activationUrl = baseUrl + "/activate?token=" + token.getToken();
helper.setText(String.format(
"""
<p>In order to complete your account registration for Coyote Credit,
please follow this link:</p>
<a href="%s">%s</a>.
<p>Note that this link will expire in 24 hours.</p>
<p>If you did not register for an account, or you are unaware of
someone registering on your behalf, you may safely ignore this
email.</p>""",
activationUrl, activationUrl
), true);
mailSender.send(msg);
}
@Scheduled(cron = "@midnight")
public void removeExpiredActivationTokens() {
activationTokenRepository.deleteAllByExpiresAtBefore(LocalDateTime.now(ZoneOffset.UTC));
} }
} }

View File

@ -0,0 +1,17 @@
package nl.andrewl.coyotecredit.util;
import java.security.SecureRandom;
import java.util.Random;
public class StringUtils {
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
public static String random(int length) {
StringBuilder sb = new StringBuilder(length);
Random rand = new SecureRandom();
for (int i = 0; i < length; i++) {
sb.append(ALPHABET.charAt(rand.nextInt(ALPHABET.length())));
}
return sb.toString();
}
}

View File

@ -4,3 +4,8 @@ spring.datasource.password=tester
spring.jpa.hibernate.ddl-auto=update spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
spring.mail.host=127.0.0.1
spring.mail.port=1025
coyote-credit.base-url=http://localhost:8080

View File

@ -0,0 +1,31 @@
@font-face {
font-family: SpaceMono;
src: url("/static/font/SpaceMono-Regular.ttf");
font-style: normal;
font-weight: normal;
}
@font-face {
font-family: SpaceMono;
src: url("/static/font/SpaceMono-Italic.ttf");
font-style: italic;
font-weight: normal;
}
@font-face {
font-family: SpaceMono;
src: url("/static/font/SpaceMono-Bold.ttf");
font-style: normal;
font-weight: bold;
}
@font-face {
font-family: SpaceMono;
src: url("/static/font/SpaceMono-BoldItalic.ttf");
font-style: italic;
font-weight: bold;
}
.currency {
font-family: SpaceMono, monospace;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,28 +8,35 @@
<div id="content" class="container"> <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>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> <p>Total value of <span class="currency" th:text="${account.totalBalance()}"></span>&nbsp;<span th:text="${account.exchange().primaryTradeable()}"></span></p>
<h3>Overview</h3> <h3>Overview</h3>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Currency</th> <th>Asset</th>
<th>Type</th>
<th>Balance</th> <th>Balance</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="bal : ${account.balances()}"> <tr th:each="bal : ${account.balances()}">
<td th:text="${bal.symbol()}"></td> <td th:text="${bal.symbol()}"></td>
<td th:text="${bal.amount()}"></td> <td th:text="${bal.type()}"></td>
<td class="currency" th:text="${bal.amount()}"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h3>Recent Transactions</h3> <a class="btn btn-success" th:href="@{/trade/{account}(account=${account.id()})}">Trade</a>
<table class="table"> <a class="btn btn-success" th:href="@{/accounts/{aId}/transfer(aId=${account.id()})}">Transfer</a>
<thead> <a class="btn btn-primary" th:if="${account.userAdmin()}" th:href="@{/accounts/{aId}/editBalances(aId=${account.id()})}">Edit Balances</a>
<div th:if="${!account.recentTransactions().isEmpty()}">
<h3>Recent Transactions</h3>
<table class="table">
<thead>
<tr> <tr>
<th>From</th> <th>From</th>
<th>Amount From</th> <th>Amount From</th>
@ -37,18 +44,16 @@
<th>Amount To</th> <th>Amount To</th>
<th>Timestamp</th> <th>Timestamp</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="tx : ${account.recentTransactions()}"> <tr th:each="tx : ${account.recentTransactions()}">
<td th:text="${tx.from().name()}"></td> <td th:text="${tx.from().name()}"></td>
<td th:text="${tx.fromAmount()}"></td> <td class="currency" th:text="${tx.fromAmount()}"></td>
<td th:text="${tx.to().name()}"></td> <td th:text="${tx.to().name()}"></td>
<td th:text="${tx.toAmount()}"></td> <td class="currency" th:text="${tx.toAmount()}"></td>
<td th:text="${tx.timestamp()}"></td> <td th:text="${tx.timestamp()}"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<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> </div>

View File

@ -10,7 +10,7 @@
<form th:action="@{/accounts/{aId}/editBalances(aId=${accountId})}" th:method="post"> <form th:action="@{/accounts/{aId}/editBalances(aId=${accountId})}" th:method="post">
<div class="mb-3" th:each="bal, iter : ${account.balances()}"> <div class="mb-3" th:each="bal, iter : ${account.balances()}">
<label th:for="${'tradeable-' + bal.id()}" class="form-label" th:text="${bal.symbol()}"></label> <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"/> <input type="number" min="0" th:value="${bal.amount()}" th:name="${'tradeable-' + bal.id()}" class="form-control" required/>
</div> </div>
<button type="submit" class="btn btn-success">Submit</button> <button type="submit" class="btn btn-success">Submit</button>
</form> </form>

View File

@ -0,0 +1,33 @@
<!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">
<form th:action="@{/accounts/{aId}/transfer(aId=${accountId})}" th:method="post">
<div class="mb-3">
<label for="recipientNumberInput" class="form-label">Recipient Account Number</label>
<input type="text" name="recipientNumber" class="form-control" id="recipientNumberInput" required/>
</div>
<div class="mb-3">
<label for="tradeableSelect" class="form-label">Asset</label>
<select class="form-select" id="tradeableSelect" name="tradeableId" required>
<option
th:each="b : ${balances}"
th:text="${b.symbol() + ' - Balance ' + b.amount()}"
th:value="${b.id()}"
></option>
</select>
</div>
<div class="mb-3">
<label for="amountInput" class="form-label">Amount</label>
<input type="number" min="0" name="amount" class="form-control" id="amountInput" required/>
</div>
<div class="mb-3">
<label for="messageTextArea" class="form-label">Message</label>
<textarea class="form-control" name="message" rows="3" id="messageTextArea"></textarea>
</div>
<button type="submit" class="btn btn-success">Submit</button>
</form>
</div>

View File

@ -7,8 +7,6 @@
<div id="content" class="container"> <div id="content" class="container">
<h1>Accounts</h1> <h1>Accounts</h1>
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}">Add Account</a>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -23,9 +21,11 @@
<td><a th:href="@{/accounts/{id}(id=${account.id()})}" th:text="${account.number()}"></a></td> <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.name()}"></td>
<td th:text="${account.admin()}"></td> <td th:text="${account.admin()}"></td>
<td th:text="${account.totalBalance()}"></td> <td class="currency" th:text="${account.totalBalance()}"></td>
<td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td> <td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}">Add Account</a>
</div> </div>

View File

@ -13,15 +13,19 @@
<form th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}" th:method="post"> <form th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}" th:method="post">
<div class="mb-3"> <div class="mb-3">
<label for="nameInput" class="form-label">Name</label> <label for="nameInput" class="form-label">Name</label>
<input id="nameInput" type="text" class="form-control" name="name"/> <input id="nameInput" type="text" class="form-control" name="name" required/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="usernameInput" class="form-label">Username</label> <label for="usernameInput" class="form-label">Username</label>
<input id="usernameInput" type="text" class="form-control" name="username"/> <input id="usernameInput" type="text" class="form-control" name="username" required/>
</div>
<div class="mb-3">
<label for="emailInput" class="form-label">Email</label>
<input id="emailInput" type="email" class="form-control" name="email" required/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="passwordInput" class="form-label">Password</label> <label for="passwordInput" class="form-label">Password</label>
<input id="passwordInput" type="password" class="form-control" name="password"/> <input id="passwordInput" type="password" class="form-control" name="password" required/>
</div> </div>
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/> <input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
<button type="submit" class="btn btn-primary">Submit</button> <button type="submit" class="btn btn-primary">Submit</button>

View File

@ -8,16 +8,16 @@
<h1 th:text="${exchange.name()}"></h1> <h1 th:text="${exchange.name()}"></h1>
<p> <p>
Primary tradeable: <span th:text="${exchange.primaryTradeable().name()}"></span> Primary asset: <span th:text="${exchange.primaryTradeable().name()}"></span>
</p> </p>
<h3>Supported Tradeable Currencies / Stocks</h3> <h3>Tradeable Assets</h3>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Symbol</th> <th>Symbol</th>
<th>Type</th> <th>Type</th>
<th>Price ($)</th> <th>Price (in USD)</th>
<th>Name</th> <th>Name</th>
</tr> </tr>
</thead> </thead>
@ -25,15 +25,14 @@
<tr th:each="tradeable : ${exchange.supportedTradeables()}"> <tr th:each="tradeable : ${exchange.supportedTradeables()}">
<td th:text="${tradeable.symbol()}"></td> <td th:text="${tradeable.symbol()}"></td>
<td th:text="${tradeable.type()}"></td> <td th:text="${tradeable.type()}"></td>
<td th:text="${tradeable.marketPriceUsd()}"></td> <td class="currency" th:text="${tradeable.formattedPriceUsd()}"></td>
<td th:text="${tradeable.name()}"></td> <td th:text="${tradeable.name()}"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div th:if="${exchange.accountAdmin()}"> <div>
<div> <a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a>
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">View Accounts</a> <a class="btn btn-primary" th:if="${exchange.accountAdmin()}" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">All Accounts</a>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Exchanges', content=~{::#content})}"
>
<div id="content" class="container">
<h1>Exchanges</h1>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Primary Asset</th>
<th>Account</th>
<th>Estimated Balance</th>
</tr>
</thead>
<tbody>
<tr th:each="ed : ${exchangeData}">
<td>
<a th:text="${ed.exchange().name()}" th:href="@{/exchanges/{eId}(eId=${ed.exchange().id()})}"></a>
</td>
<td th:text="${ed.exchange().primaryTradeable()}"></td>
<td>
<a th:text="${ed.account().number()}" th:href="@{/accounts/{aId}(aId=${ed.account().id()})}"></a>
</td>
<td class="currency" th:text="${ed.account().totalBalance()}"></td>
</tr>
</tbody>
</table>
<p>
Use this page to view a list of all exchanges you're participating in. Click on the <strong>name</strong> of the exchange to view its page, and click on your <strong>account</strong> number to view your account information for a given exchange. The <strong>estimated balance</strong> shown in this overview may not be completely accurate. Navigate to your account page for a complete overview of your current account balances for stocks, fiat currencies, and more.
</p>
</div>

View File

@ -21,6 +21,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" th:href="@{/}">Home</a> <a class="nav-link" th:href="@{/}">Home</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" th:href="@{/exchanges}">Exchanges</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">My Profile</a> <a class="nav-link" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">My Profile</a>
</li> </li>

View File

@ -6,15 +6,19 @@
> >
<div id="content" class="container"> <div id="content" class="container">
<h1>Welcome!</h1> <div class="row justify-content-center">
<div class="col-lg-6">
<h1 class="display-4">Welcome to Coyote Credit</h1>
<h3>Your Accounts:</h3> <p class="lead">
<ul> A simulated asset trading platform developed for building a stronger understanding of investment and wealth management.
<li th:each="account : ${accounts}"> </p>
<a th:href="@{/accounts/{id}(id=${account.id()})}">
<span th:text="${account.accountNumber()}"></span> @ <hr>
<span th:text="${account.exchangeName()}"></span>
</a> <p>
</li> You can visit the <a th:href="@{/exchanges}">Exchanges</a> page to view a list of exchanges that you're participating in.
</ul> </p>
</div>
</div>
</div> </div>

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
>
<head>
<meta charset="UTF-8">
<title>CC - Login</title>
</head>
<body>
<form th:action="@{/login/processing}" th:method="post">
<label for="usernameInput">Username</label>
<input name="username" type="text" id="usernameInput">
<label for="passwordInput">Password</label>
<input name="password" type="password" id="passwordInput">
<input type="submit" value="Login">
</form>
</body>
</html>

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Login', content=~{::#content})}"
>
<div id="content" class="container">
<div class="row justify-content-center">
<div class="col-lg-4">
<h1>Login</h1>
<form th:action="@{/login/processing}" th:method="post">
<div class="mb-3">
<label for="usernameInput" class="form-label">Username</label>
<input name="username" class="form-control" type="text" id="usernameInput" required>
</div>
<div class="mb-3">
<label for="passwordInput" class="form-label">Password</label>
<input name="password" class="form-control" type="password" id="passwordInput" required>
</div>
<input type="submit" class="btn btn-primary" value="Login">
<a class="btn btn-secondary" th:href="@{/register}">Register</a>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Register', content=~{::#content})}"
>
<div id="content" class="container">
<div class="row justify-content-center">
<div class="col-lg-4">
<h1>Register</h1>
<form th:action="@{/register}" th:method="post">
<div class="mb-3">
<label for="usernameInput" class="form-label">Username</label>
<input name="username" id="usernameInput" class="form-control" type="text" required/>
</div>
<div class="mb-3">
<label for="emailInput" class="form-label">Email</label>
<input name="email" id="emailInput" class="form-control" type="email" required/>
</div>
<div class="mb-3">
<label for="passwordInput" class="form-label">Password</label>
<input name="password" id="passwordInput" class="form-control" type="password" required/>
</div>
<input type="submit" class="btn btn-primary" value="Register">
</form>
</div>
</div>
</div>

View File

@ -13,6 +13,10 @@
<th scope="row">Username</th> <th scope="row">Username</th>
<td th:text="${user.username()}"></td> <td th:text="${user.username()}"></td>
</tr> </tr>
<tr>
<th scope="row">Email</th>
<td th:text="${user.email()}"></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>