Improved account transfer UI, added config-based api switch.

This commit is contained in:
Andrew Lalis 2022-02-27 11:30:33 +01:00
parent 506a474819
commit db2bcb576f
23 changed files with 138 additions and 30 deletions

View File

@ -1,7 +1,10 @@
package nl.andrewl.coyotecredit.ctl.api; package nl.andrewl.coyotecredit.ctl.api;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.exchange.dto.PublicAccountData;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.ExchangeService; import nl.andrewl.coyotecredit.service.ExchangeService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -16,7 +19,12 @@ public class ExchangeApiController {
private final ExchangeService exchangeService; private final ExchangeService exchangeService;
@GetMapping(path = "/tradeables") @GetMapping(path = "/tradeables")
public Map<Long, String> getCurrentTradeables(@PathVariable long exchangeId) { public Map<Long, String> getCurrentTradeables(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
return exchangeService.getCurrentTradeables(exchangeId); return exchangeService.getCurrentTradeables(exchangeId, user);
}
@GetMapping(path = "/accounts/{number}")
public PublicAccountData getAccountData(@PathVariable long exchangeId, @PathVariable String number, @AuthenticationPrincipal User user) {
return exchangeService.getPublicAccountData(exchangeId, number, user);
} }
} }

View File

@ -1,7 +1,7 @@
package nl.andrewl.coyotecredit.ctl; package nl.andrewl.coyotecredit.ctl.exchange;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.TransferPayload; import nl.andrewl.coyotecredit.ctl.exchange.dto.TransferPayload;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.AccountService; import nl.andrewl.coyotecredit.service.AccountService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -45,7 +45,7 @@ public class AccountPage {
@GetMapping(path = "/transfer") @GetMapping(path = "/transfer")
public String getTransferPage(Model model, @PathVariable long accountId, @AuthenticationPrincipal User user) { public String getTransferPage(Model model, @PathVariable long accountId, @AuthenticationPrincipal User user) {
model.addAttribute("balances", accountService.getTransferData(accountId, user)); model.addAttribute("data", accountService.getTransferData(accountId, user));
return "account/transfer"; return "account/transfer";
} }

View File

@ -1,7 +1,7 @@
package nl.andrewl.coyotecredit.ctl; package nl.andrewl.coyotecredit.ctl.exchange;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.TradePayload; import nl.andrewl.coyotecredit.ctl.exchange.dto.TradePayload;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.ExchangeService; import nl.andrewl.coyotecredit.service.ExchangeService;
import nl.andrewl.coyotecredit.service.TradeService; import nl.andrewl.coyotecredit.service.TradeService;
@ -26,7 +26,7 @@ public class TradePage {
@AuthenticationPrincipal User user @AuthenticationPrincipal User user
) { ) {
model.addAttribute("data", tradeService.getTradeData(accountId, user)); model.addAttribute("data", tradeService.getTradeData(accountId, user));
return "trade"; return "account/trade";
} }
@PostMapping @PostMapping

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record BalanceData( public record BalanceData(
long id, long id,

View File

@ -1,7 +1,5 @@
package nl.andrewl.coyotecredit.ctl.exchange.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.SimpleAccountData;
public record ExchangeAccountData( public record ExchangeAccountData(
ExchangeData exchange, ExchangeData exchange,
SimpleAccountData account SimpleAccountData account

View File

@ -1,6 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.exchange.dto.ExchangeData;
import java.util.List; import java.util.List;

View File

@ -0,0 +1,10 @@
package nl.andrewl.coyotecredit.ctl.exchange.dto;
/**
* Account data that can be publicly seen by any member of an exchange.
*/
public record PublicAccountData(
long id,
String number,
String name
) {}

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record SimpleAccountData ( public record SimpleAccountData (
long id, long id,

View File

@ -1,4 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
/** /**
* The payload that's sent when a user performs a trade with the market. This * The payload that's sent when a user performs a trade with the market. This

View File

@ -1,5 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import nl.andrewl.coyotecredit.model.Transaction; import nl.andrewl.coyotecredit.model.Transaction;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View File

@ -1,5 +1,6 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import nl.andrewl.coyotecredit.model.Transfer; import nl.andrewl.coyotecredit.model.Transfer;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View File

@ -0,0 +1,9 @@
package nl.andrewl.coyotecredit.ctl.exchange.dto;
import java.util.List;
public record TransferPageData(
long exchangeId,
List<BalanceData> balances
) {
}

View File

@ -1,4 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.exchange.dto;
public record TransferPayload ( public record TransferPayload (
String recipientNumber, String recipientNumber,

View File

@ -14,6 +14,7 @@ public interface AccountRepository extends JpaRepository<Account, Long> {
List<Account> findAllByUser(User user); List<Account> findAllByUser(User user);
Optional<Account> findByNumber(String number); Optional<Account> findByNumber(String number);
Optional<Account> findByUserAndExchange(User user, Exchange exchange); Optional<Account> findByUserAndExchange(User user, Exchange exchange);
Optional<Account> findByNumberAndExchange(String number, Exchange exchange);
boolean existsByUserAndExchange(User user, Exchange exchange); boolean existsByUserAndExchange(User user, Exchange exchange);
List<Account> findAllByExchange(Exchange e); List<Account> findAllByExchange(Exchange e);

View File

@ -2,7 +2,7 @@ package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.*; import nl.andrewl.coyotecredit.ctl.dto.*;
import nl.andrewl.coyotecredit.ctl.exchange.dto.ExchangeData; import nl.andrewl.coyotecredit.ctl.exchange.dto.*;
import nl.andrewl.coyotecredit.dao.*; import nl.andrewl.coyotecredit.dao.*;
import nl.andrewl.coyotecredit.model.*; import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.util.AccountNumberUtils; import nl.andrewl.coyotecredit.util.AccountNumberUtils;
@ -34,13 +34,13 @@ public class AccountService {
private final UserNotificationRepository notificationRepository; private final UserNotificationRepository notificationRepository;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<BalanceData> getTransferData(long accountId, User user) { public TransferPageData getTransferData(long accountId, User user) {
Account account = accountRepository.findById(accountId) Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!account.getUser().getId().equals(user.getId())) { if (!account.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND); throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} }
return account.getBalances().stream() List<BalanceData> balances = account.getBalances().stream()
.filter(b -> b.getAmount().compareTo(BigDecimal.ZERO) > 0) .filter(b -> b.getAmount().compareTo(BigDecimal.ZERO) > 0)
.map(b -> new BalanceData( .map(b -> new BalanceData(
b.getTradeable().getId(), b.getTradeable().getId(),
@ -50,6 +50,7 @@ public class AccountService {
)) ))
.sorted(Comparator.comparing(BalanceData::symbol)) .sorted(Comparator.comparing(BalanceData::symbol))
.toList(); .toList();
return new TransferPageData(account.getExchange().getId(), balances);
} }
@Transactional @Transactional

View File

@ -103,6 +103,18 @@ public class ExchangeService {
.toList(); .toList();
} }
@Transactional(readOnly = true)
public PublicAccountData getPublicAccountData(long exchangeId, String number, User user) {
Exchange exchange = exchangeRepository.findById(exchangeId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!accountRepository.existsByUserAndExchange(user, exchange)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
Account account = accountRepository.findByNumberAndExchange(number, exchange)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new PublicAccountData(account.getId(), account.getNumber(), account.getName());
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public void ensureAdminAccount(long exchangeId, User user) { public void ensureAdminAccount(long exchangeId, User user) {
getExchangeIfAdmin(exchangeId, user); getExchangeIfAdmin(exchangeId, user);
@ -168,9 +180,12 @@ public class ExchangeService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Map<Long, String> getCurrentTradeables(long exchangeId) { public Map<Long, String> getCurrentTradeables(long exchangeId, User user) {
Exchange e = exchangeRepository.findById(exchangeId) Exchange e = exchangeRepository.findById(exchangeId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!accountRepository.existsByUserAndExchange(user, e)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
Map<Long, String> tradeables = new HashMap<>(); Map<Long, String> tradeables = new HashMap<>();
for (var t : e.getAllTradeables()) { for (var t : e.getAllTradeables()) {
tradeables.put(t.getId(), t.getMarketPriceUsd().toPlainString()); tradeables.put(t.getId(), t.getMarketPriceUsd().toPlainString());

View File

@ -1,7 +1,7 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.TradeData; import nl.andrewl.coyotecredit.ctl.exchange.dto.TradeData;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData; import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import nl.andrewl.coyotecredit.dao.AccountRepository; import nl.andrewl.coyotecredit.dao.AccountRepository;
import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.model.Account;

View File

@ -9,6 +9,7 @@ import nl.andrewl.coyotecredit.dao.TradeableRepository;
import nl.andrewl.coyotecredit.model.Tradeable; import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.TradeableType; import nl.andrewl.coyotecredit.model.TradeableType;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -32,6 +33,7 @@ import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
public class TradeableUpdateService { public class TradeableUpdateService {
private final TradeableRepository tradeableRepository; private final TradeableRepository tradeableRepository;
private final Environment environment;
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@ -57,7 +59,11 @@ public class TradeableUpdateService {
for (var tradeable : publicTradeables) { for (var tradeable : publicTradeables) {
// Special case of ignoring USD as the universal transfer currency. // Special case of ignoring USD as the universal transfer currency.
if (tradeable.getSymbol().equals("USD")) continue; if (tradeable.getSymbol().equals("USD")) continue;
executorService.schedule(() -> updateTradeable(tradeable), delay, TimeUnit.SECONDS); if (environment.getProperty("coyote-credit.enable-tradeable-updates", Boolean.TYPE, true)) {
executorService.schedule(() -> updateTradeable(tradeable), delay, TimeUnit.SECONDS);
} else {
log.info("Tradeable update skipped for {}.", tradeable.getSymbol());
}
delay += POLYGON_API_TIMEOUT; delay += POLYGON_API_TIMEOUT;
} }
} }

View File

@ -10,3 +10,4 @@ spring.mail.host=127.0.0.1
spring.mail.port=1025 spring.mail.port=1025
coyote-credit.base-url=http://localhost:8080 coyote-credit.base-url=http://localhost:8080
coyote-credit.enable-tradeable-updates=false

View File

@ -1,7 +1,14 @@
const tradeableSelect = document.getElementById("tradeableSelect"); const tradeableSelect = document.getElementById("tradeableSelect");
const valueInput = document.getElementById("amountInput"); const valueInput = document.getElementById("amountInput");
const recipientNumberInput = document.getElementById("recipientNumberInput");
const recipientNumberNote = document.getElementById("recipientNumberNote");
const exchangeId = document.getElementById("exchangeIdInput").value;
const accountNumberRegex = new RegExp("\\d{4}-\\d{4}-\\d{4}-\\d{4}");
tradeableSelect.addEventListener("change", onSelectChanged); tradeableSelect.addEventListener("change", onSelectChanged);
recipientNumberInput.addEventListener("change", onAccountNumberChanged);
recipientNumberInput.addEventListener("keyup", onAccountNumberChanged);
function onSelectChanged() { function onSelectChanged() {
valueInput.value = null; valueInput.value = null;
@ -17,3 +24,52 @@ function onSelectChanged() {
valueInput.setAttribute("min", 0.0000000001); valueInput.setAttribute("min", 0.0000000001);
} }
} }
/**
* Check that the account number that was entered is valid.
*/
function onAccountNumberChanged() {
const currentNumber = recipientNumberInput.value;
console.log("Account number changed to " + currentNumber);
if (currentNumber !== undefined && currentNumber.length > 0) {
if (accountNumberRegex.test(currentNumber)) {
recipientNumberNote.innerText = "Valid account number.";
fetch("/api/exchanges/" + exchangeId + "/accounts/" + currentNumber)
.then(response => {
if (response.status === 200) {
response.json()
.then(data => {
console.log(data);
showRecipientNumberNote("info", "Account number is valid. Will transfer to <span class='fw-bold'>" + data.name + "</span>");
})
.catch(() => {
showRecipientNumberNote("warning", "Could not find an account with that number.");
});
} else if (response.status === 404) {
showRecipientNumberNote("warning", "Could not find an account with that number.");
} else {
showRecipientNumberNote("warning", "Error: Couldn't read API response.");
}
})
.catch(error => {
console.log(error);
showRecipientNumberNote("warning", "API error occurred. Couldn't fetch account information.");
});
} else {
showRecipientNumberNote("warning", "Invalid account number. Format: <span class='monospace'>1234-1234-1234-1234</span>");
}
} else {
showRecipientNumberNote("info", "Enter an account number.");
}
}
function showRecipientNumberNote(type, text) {
recipientNumberNote.innerHTML = text;
if (type === "warning") {
recipientNumberNote.classList.add("text-danger");
recipientNumberNote.classList.remove("text-muted");
} else {
recipientNumberNote.classList.remove("text-danger");
recipientNumberNote.classList.add("text-muted");
}
}

View File

@ -10,11 +10,12 @@
Transfer funds to other accounts. Transfer funds to other accounts.
</p> </p>
<form th:action="@{/accounts/{aId}/transfer(aId=${accountId})}" th:method="post"> <form th:action="@{/accounts/{aId}/transfer(aId=${accountId})}" th:method="post">
<input type="hidden" id="exchangeIdInput" th:value="${data.exchangeId()}"/>
<div class="mb-3"> <div class="mb-3">
<label for="recipientNumberInput" class="form-label">Recipient Account Number</label> <label for="recipientNumberInput" class="form-label">Recipient Account Number</label>
<input type="text" name="recipientNumber" class="form-control" id="recipientNumberInput" required/> <input type="text" name="recipientNumber" class="form-control monospace" id="recipientNumberInput" required/>
<small class="text-muted"> <small class="text-muted text-danger" id="recipientNumberNote">
Format: <span class="monospace">1234-1234-1234-1234</span><br> Enter an account number.
</small> </small>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -22,7 +23,7 @@
<select class="form-select" id="tradeableSelect" name="tradeableId" required> <select class="form-select" id="tradeableSelect" name="tradeableId" required>
<option value="" selected disabled hidden>Choose something to send</option> <option value="" selected disabled hidden>Choose something to send</option>
<option <option
th:each="b : ${balances}" th:each="b : ${data.balances()}"
th:text="${b.symbol() + ' - Balance ' + b.amount()}" th:text="${b.symbol() + ' - Balance ' + b.amount()}"
th:value="${b.id()}" th:value="${b.id()}"
th:data-amount="${b.amount()}" th:data-amount="${b.amount()}"