Added transfer, more improvements.
This commit is contained in:
parent
0538334df4
commit
6183ccbea9
|
@ -31,3 +31,5 @@ build/
|
|||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
/config/
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -26,7 +26,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
|||
http
|
||||
.authorizeRequests()
|
||||
.antMatchers(
|
||||
"/login", "/login/processing", "/static/**"
|
||||
"/login*", "/login/processing", "/register*", "/activate*", "/static/**"
|
||||
).permitAll()
|
||||
.and()
|
||||
.authorizeRequests().anyRequest().authenticated()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package nl.andrewl.coyotecredit.ctl;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.TransferPayload;
|
||||
import nl.andrewl.coyotecredit.model.User;
|
||||
import nl.andrewl.coyotecredit.service.AccountService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
@ -12,8 +12,6 @@ import org.springframework.util.MultiValueMap;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = "/accounts/{accountId}")
|
||||
@RequiredArgsConstructor
|
||||
|
@ -44,4 +42,16 @@ public class AccountPage {
|
|||
accountService.editBalances(accountId, user, paramMap);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,42 +10,48 @@ import org.springframework.ui.Model;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = "/exchanges/{exchangeId}")
|
||||
@RequestMapping(path = "/exchanges")
|
||||
@RequiredArgsConstructor
|
||||
public class ExchangePage {
|
||||
public class ExchangeController {
|
||||
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";
|
||||
public String getExchanges(Model model, @AuthenticationPrincipal User user) {
|
||||
model.addAttribute("exchangeData", exchangeService.getExchanges(user));
|
||||
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) {
|
||||
model.addAttribute("accounts", exchangeService.getAccounts(exchangeId, user));
|
||||
return "exchange/accounts";
|
||||
}
|
||||
|
||||
@GetMapping(path = "/addAccount")
|
||||
@GetMapping(path = "/{exchangeId}/addAccount")
|
||||
public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
|
||||
exchangeService.ensureAdminAccount(exchangeId, user);
|
||||
return "exchange/addAccount";
|
||||
}
|
||||
|
||||
@PostMapping(path = "/addAccount")
|
||||
@PostMapping(path = "/{exchangeId}/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}")
|
||||
@GetMapping(path = "/{exchangeId}/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}")
|
||||
@PostMapping(path = "/{exchangeId}/removeAccount/{accountId}")
|
||||
public String postRemoveAccount(@PathVariable long exchangeId, @PathVariable long accountId, @AuthenticationPrincipal User user) {
|
||||
exchangeService.removeAccount(exchangeId, accountId, user);
|
||||
return "redirect:/exchanges/" + exchangeId + "/accounts";
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
|
||||
public record AddAccountPayload(
|
||||
String name,
|
||||
String email,
|
||||
String username,
|
||||
String password
|
||||
) {}
|
||||
|
|
|
@ -3,5 +3,6 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
public record BalanceData(
|
||||
long id,
|
||||
String symbol,
|
||||
String type,
|
||||
String amount
|
||||
) {}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record ExchangeAccountData(
|
||||
ExchangeData exchange,
|
||||
SimpleAccountData account
|
||||
) {}
|
|
@ -10,7 +10,7 @@ public record FullExchangeData (
|
|||
String name,
|
||||
TradeableData primaryTradeable,
|
||||
List<TradeableData> supportedTradeables,
|
||||
// Account info
|
||||
boolean accountAdmin
|
||||
) {
|
||||
}
|
||||
// Account info that's needed for determining if it's possible to do some actions.
|
||||
boolean accountAdmin,
|
||||
long accountId
|
||||
) {}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record RegisterPayload (
|
||||
String username,
|
||||
String email,
|
||||
String password
|
||||
) {}
|
|
@ -2,20 +2,26 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
|
||||
import nl.andrewl.coyotecredit.model.Tradeable;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
public record TradeableData(
|
||||
long id,
|
||||
String symbol,
|
||||
String type,
|
||||
String marketPriceUsd,
|
||||
String formattedPriceUsd,
|
||||
String name,
|
||||
String description
|
||||
) {
|
||||
public static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,##0.00");
|
||||
|
||||
public TradeableData(Tradeable t) {
|
||||
this(
|
||||
t.getId(),
|
||||
t.getSymbol(),
|
||||
t.getType().name(),
|
||||
t.getMarketPriceUsd().toPlainString(),
|
||||
DECIMAL_FORMAT.format(t.getMarketPriceUsd()),
|
||||
t.getName(),
|
||||
t.getDescription()
|
||||
);
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record TransferPayload (
|
||||
String recipientNumber,
|
||||
String amount,
|
||||
long tradeableId,
|
||||
String message
|
||||
) {}
|
|
@ -2,5 +2,6 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
|
||||
public record UserData (
|
||||
long id,
|
||||
String username
|
||||
String username,
|
||||
String email
|
||||
) {}
|
||||
|
|
|
@ -4,6 +4,9 @@ import nl.andrewl.coyotecredit.model.Tradeable;
|
|||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface TradeableRepository extends JpaRepository<Tradeable, Long> {
|
||||
List<Tradeable> findAllByExchangeNull();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -9,4 +9,5 @@ import java.util.Optional;
|
|||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -47,7 +47,7 @@ public class Exchange {
|
|||
* 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;
|
||||
private Set<Tradeable> customTradeables;
|
||||
|
||||
/**
|
||||
* The set of accounts that are registered with this exchange.
|
||||
|
|
|
@ -3,6 +3,7 @@ package nl.andrewl.coyotecredit.model;
|
|||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
|
@ -29,6 +30,7 @@ public class Tradeable {
|
|||
private TradeableType type;
|
||||
|
||||
@Column(nullable = false, precision = 24, scale = 10)
|
||||
@Setter
|
||||
private BigDecimal marketPriceUsd = new BigDecimal(1);
|
||||
|
||||
@Column(nullable = false)
|
||||
|
@ -37,12 +39,19 @@ public class Tradeable {
|
|||
@Column
|
||||
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.type = type;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.marketPriceUsd = marketPriceUsd;
|
||||
this.exchange = exchange;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -3,10 +3,13 @@ package nl.andrewl.coyotecredit.model;
|
|||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
|
@ -29,15 +32,27 @@ public class User implements UserDetails {
|
|||
@Column(nullable = false)
|
||||
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.
|
||||
*/
|
||||
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<Account> accounts;
|
||||
|
||||
public User(String username, String passwordHash) {
|
||||
public User(String username, String passwordHash, String email) {
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.email = email;
|
||||
this.createdAt = LocalDateTime.now(ZoneOffset.UTC);
|
||||
this.accounts = new HashSet<>();
|
||||
}
|
||||
|
||||
|
@ -75,6 +90,6 @@ public class User implements UserDetails {
|
|||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
return this.activated;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,17 +1,12 @@
|
|||
package nl.andrewl.coyotecredit.service;
|
||||
|
||||
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.ctl.dto.*;
|
||||
import nl.andrewl.coyotecredit.dao.AccountRepository;
|
||||
import nl.andrewl.coyotecredit.dao.TradeableRepository;
|
||||
import nl.andrewl.coyotecredit.dao.TransactionRepository;
|
||||
import nl.andrewl.coyotecredit.model.Account;
|
||||
import nl.andrewl.coyotecredit.model.Balance;
|
||||
import nl.andrewl.coyotecredit.model.Tradeable;
|
||||
import nl.andrewl.coyotecredit.model.User;
|
||||
import nl.andrewl.coyotecredit.dao.TransferRepository;
|
||||
import nl.andrewl.coyotecredit.model.*;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
@ -29,6 +24,61 @@ public class AccountService {
|
|||
private final AccountRepository accountRepository;
|
||||
private final TransactionRepository transactionRepository;
|
||||
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 (
|
||||
long id,
|
||||
|
@ -71,10 +121,15 @@ public class AccountService {
|
|||
account.getExchange().getPrimaryTradeable().getSymbol()
|
||||
),
|
||||
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))
|
||||
.toList(),
|
||||
account.getTotalBalance().toPlainString(),
|
||||
TradeableData.DECIMAL_FORMAT.format(account.getTotalBalance()),
|
||||
transactionData
|
||||
);
|
||||
}
|
||||
|
|
|
@ -45,7 +45,8 @@ public class ExchangeService {
|
|||
.map(TradeableData::new)
|
||||
.sorted(Comparator.comparing(TradeableData::symbol))
|
||||
.toList(),
|
||||
account.isAdmin()
|
||||
account.isAdmin(),
|
||||
account.getId()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -91,7 +92,7 @@ public class ExchangeService {
|
|||
if (!account.isAdmin()) {
|
||||
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(
|
||||
AccountNumberUtils.generate(),
|
||||
u,
|
||||
|
@ -188,4 +189,15 @@ public class ExchangeService {
|
|||
Transaction tx = new Transaction(account.getNumber(), exchange, from, fromValue, to, toValue, LocalDateTime.now(ZoneOffset.UTC));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,40 @@
|
|||
package nl.andrewl.coyotecredit.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.UserData;
|
||||
import nl.andrewl.coyotecredit.dao.UserActivationTokenRepository;
|
||||
import nl.andrewl.coyotecredit.dao.UserRepository;
|
||||
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.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.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.mail.MessagingException;
|
||||
import javax.mail.internet.MimeMessage;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
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) {
|
||||
User user;
|
||||
if (requestingUser.getId().equals(userId)) {
|
||||
|
@ -22,6 +42,62 @@ public class UserService {
|
|||
} else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -4,3 +4,8 @@ spring.datasource.password=tester
|
|||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.open-in-view=false
|
||||
|
||||
spring.mail.host=127.0.0.1
|
||||
spring.mail.port=1025
|
||||
|
||||
coyote-credit.base-url=http://localhost:8080
|
||||
|
|
|
@ -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.
Binary file not shown.
|
@ -8,28 +8,35 @@
|
|||
<div id="content" class="container">
|
||||
<h1>Account <span th:text="${account.number()}"></span></h1>
|
||||
<p>In <a th:href="@{/exchanges/{id}(id=${account.exchange().id()})}" th:text="${account.exchange().name()}"></a></p>
|
||||
<p>Total value of <span th:text="${account.totalBalance() + ' ' + account.exchange().primaryTradeable()}"></span></p>
|
||||
<p>Total value of <span class="currency" th:text="${account.totalBalance()}"></span> <span th:text="${account.exchange().primaryTradeable()}"></span></p>
|
||||
|
||||
<h3>Overview</h3>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Currency</th>
|
||||
<th>Asset</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="bal : ${account.balances()}">
|
||||
<td th:text="${bal.symbol()}"></td>
|
||||
<td th:text="${bal.amount()}"></td>
|
||||
<td th:text="${bal.type()}"></td>
|
||||
<td class="currency" th:text="${bal.amount()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Recent Transactions</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<a class="btn btn-success" th:href="@{/trade/{account}(account=${account.id()})}">Trade</a>
|
||||
<a class="btn btn-success" th:href="@{/accounts/{aId}/transfer(aId=${account.id()})}">Transfer</a>
|
||||
<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>
|
||||
<th>From</th>
|
||||
<th>Amount From</th>
|
||||
|
@ -37,18 +44,16 @@
|
|||
<th>Amount To</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="tx : ${account.recentTransactions()}">
|
||||
<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.toAmount()}"></td>
|
||||
<td class="currency" 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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
|
@ -10,7 +10,7 @@
|
|||
<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"/>
|
||||
<input type="number" min="0" th:value="${bal.amount()}" th:name="${'tradeable-' + bal.id()}" class="form-control" required/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Submit</button>
|
||||
</form>
|
||||
|
|
|
@ -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>
|
|
@ -7,8 +7,6 @@
|
|||
<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>
|
||||
|
@ -23,9 +21,11 @@
|
|||
<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 class="currency" th:text="${account.totalBalance()}"></td>
|
||||
<td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}">Add Account</a>
|
||||
</div>
|
||||
|
|
|
@ -13,15 +13,19 @@
|
|||
<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"/>
|
||||
<input id="nameInput" type="text" class="form-control" name="name" required/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
|
|
|
@ -8,16 +8,16 @@
|
|||
<h1 th:text="${exchange.name()}"></h1>
|
||||
|
||||
<p>
|
||||
Primary tradeable: <span th:text="${exchange.primaryTradeable().name()}"></span>
|
||||
Primary asset: <span th:text="${exchange.primaryTradeable().name()}"></span>
|
||||
</p>
|
||||
|
||||
<h3>Supported Tradeable Currencies / Stocks</h3>
|
||||
<h3>Tradeable Assets</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Type</th>
|
||||
<th>Price ($)</th>
|
||||
<th>Price (in USD)</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -25,15 +25,14 @@
|
|||
<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 class="currency" th:text="${tradeable.formattedPriceUsd()}"></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>
|
||||
<a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a>
|
||||
<a class="btn btn-primary" th:if="${exchange.accountAdmin()}" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">All Accounts</a>
|
||||
</div>
|
||||
</div>
|
|
@ -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>
|
|
@ -21,6 +21,9 @@
|
|||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/}">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/exchanges}">Exchanges</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/users/{id}(id=${#authentication.getPrincipal().getId()})}">My Profile</a>
|
||||
</li>
|
||||
|
|
|
@ -6,15 +6,19 @@
|
|||
>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
<li th:each="account : ${accounts}">
|
||||
<a th:href="@{/accounts/{id}(id=${account.id()})}">
|
||||
<span th:text="${account.accountNumber()}"></span> @
|
||||
<span th:text="${account.exchangeName()}"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="lead">
|
||||
A simulated asset trading platform developed for building a stronger understanding of investment and wealth management.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
You can visit the <a th:href="@{/exchanges}">Exchanges</a> page to view a list of exchanges that you're participating in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -13,6 +13,10 @@
|
|||
<th scope="row">Username</th>
|
||||
<td th:text="${user.username()}"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Email</th>
|
||||
<td th:text="${user.email()}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
Loading…
Reference in New Issue