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 ###
.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
.authorizeRequests()
.antMatchers(
"/login", "/login/processing", "/static/**"
"/login*", "/login/processing", "/register*", "/activate*", "/static/**"
).permitAll()
.and()
.authorizeRequests().anyRequest().authenticated()

View File

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

View File

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

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(
String name,
String email,
String username,
String password
) {}

View File

@ -3,5 +3,6 @@ package nl.andrewl.coyotecredit.ctl.dto;
public record BalanceData(
long id,
String symbol,
String type,
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,
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
) {}

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

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 (
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.stereotype.Repository;
import java.util.List;
@Repository
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
public interface UserRepository extends JpaRepository<User, Long> {
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.
*/
@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.

View File

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

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

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

View File

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

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

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.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">
<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>&nbsp;<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>

View File

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

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

View File

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

View File

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

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

View File

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

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>
<td th:text="${user.username()}"></td>
</tr>
<tr>
<th scope="row">Email</th>
<td th:text="${user.email()}"></td>
</tr>
</tbody>
</table>
</div>