Payment creation with validation and response

* Move mapping inside service
 * Extract validation to utility class
 * Add test for validation
This commit is contained in:
2023-05-12 11:42:21 +02:00
parent 2f8bf45383
commit 1757546f29
18 changed files with 615 additions and 61 deletions

View File

@@ -8,7 +8,6 @@ import org.springframework.web.client.RestTemplate;
@Configuration
public class BankingServiceConfig {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();

View File

@@ -1,12 +1,12 @@
package net.kapcake.bankingservice.controllers;
import jakarta.validation.Valid;
import net.kapcake.bankingservice.converters.BankAccountConverter;
import net.kapcake.bankingservice.converters.PaymentConverter;
import net.kapcake.bankingservice.exceptions.PaymentValidationException;
import net.kapcake.bankingservice.model.domain.PaymentDTO;
import net.kapcake.bankingservice.model.domain.BankAccountDTO;
import net.kapcake.bankingservice.model.entities.BankAccount;
import net.kapcake.bankingservice.model.entities.Payment;
import net.kapcake.bankingservice.repositories.BankAccountRepository;
import net.kapcake.bankingservice.security.UserDetailsImpl;
import net.kapcake.bankingservice.services.AccountService;
import net.kapcake.bankingservice.services.PaymentService;
@@ -19,26 +19,20 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
@RestController
public class BankingServiceController {
private final BankAccountRepository bankAccountRepository;
private final AccountService accountService;
private final PaymentService paymentService;
private final ModelMapper modelMapper;
public BankingServiceController(BankAccountRepository bankAccountRepository, AccountService accountService, PaymentService paymentService, ModelMapper modelMapper) {
this.bankAccountRepository = bankAccountRepository;
public BankingServiceController(AccountService accountService, PaymentService paymentService) {
this.accountService = accountService;
this.paymentService = paymentService;
this.modelMapper = modelMapper;
}
@GetMapping("/accounts")
public List<BankAccountDTO> getAccounts(@AuthenticationPrincipal UserDetailsImpl authenticatedUser) {
return accountService.getAccounts(authenticatedUser.user()).stream()
.map(account -> modelMapper.map(account, BankAccountDTO.class)).toList();
return accountService.getAccounts(authenticatedUser);
}
@PostMapping("/payment")
@@ -46,23 +40,8 @@ public class BankingServiceController {
if (bindingResult.hasErrors()) {
throw new PaymentValidationException("Payment request invalid: " + bindingResult.getAllErrors());
}
Payment payment = mapToEntity(paymentDTO);
Payment createdPayment = paymentService.createPayment(authenticatedUser.user(), payment);
return mapToDTO(createdPayment);
return paymentService.createPayment(authenticatedUser, paymentDTO);
}
private PaymentDTO mapToDTO(Payment payment) {
PaymentDTO paymentDTO = modelMapper.map(payment, PaymentDTO.class);
return paymentDTO;
}
private Payment mapToEntity(PaymentDTO paymentDTO) {
Payment payment = modelMapper.map(paymentDTO, Payment.class);
Optional<BankAccount> bankAccountOptional = bankAccountRepository.findById(paymentDTO.getGiverAccount());
if (bankAccountOptional.isEmpty()) {
throw new PaymentValidationException("Payment request invalid: Giver account does not exist.");
}
payment.setGiverAccount(bankAccountOptional.get());
return payment;
}
}

View File

@@ -0,0 +1,20 @@
package net.kapcake.bankingservice.converters;
import net.kapcake.bankingservice.model.domain.PaymentDTO;
import net.kapcake.bankingservice.model.entities.Payment;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
public abstract class AbstractConverter<E, D> {
protected final ModelMapper modelMapper;
public AbstractConverter(ModelMapper modelMapper) {
this.modelMapper = modelMapper;
this.modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
}
public abstract D mapToDTO(E entity);
public abstract E mapToEntity(D dto);
}

View File

@@ -0,0 +1,23 @@
package net.kapcake.bankingservice.converters;
import net.kapcake.bankingservice.model.domain.BankAccountDTO;
import net.kapcake.bankingservice.model.entities.BankAccount;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
@Component
public class BankAccountConverter extends AbstractConverter<BankAccount, BankAccountDTO> {
public BankAccountConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public BankAccountDTO mapToDTO(BankAccount bankAccount) {
return modelMapper.map(bankAccount, BankAccountDTO.class);
}
@Override
public BankAccount mapToEntity(BankAccountDTO bankAccountDTO) {
return modelMapper.map(bankAccountDTO, BankAccount.class);
}
}

View File

@@ -0,0 +1,43 @@
package net.kapcake.bankingservice.converters;
import net.kapcake.bankingservice.exceptions.PaymentValidationException;
import net.kapcake.bankingservice.model.domain.PaymentDTO;
import net.kapcake.bankingservice.model.entities.BankAccount;
import net.kapcake.bankingservice.model.entities.Payment;
import net.kapcake.bankingservice.repositories.BankAccountRepository;
import org.modelmapper.ModelMapper;
import org.modelmapper.TypeMap;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class PaymentConverter extends AbstractConverter<Payment, PaymentDTO> {
private final BankAccountRepository bankAccountRepository;
public PaymentConverter(BankAccountRepository bankAccountRepository, ModelMapper modelMapper) {
super(modelMapper);
this.bankAccountRepository = bankAccountRepository;
}
@Override
public PaymentDTO mapToDTO(Payment payment) {
TypeMap<Payment, PaymentDTO> paymentPaymentDTOTypeMap = modelMapper
.typeMap(Payment.class, PaymentDTO.class)
.addMapping(src -> src.getGiverAccount().getId(), PaymentDTO::setGiverAccount);
PaymentDTO paymentDTO = paymentPaymentDTOTypeMap.map(payment);
return paymentDTO;
}
@Override
public Payment mapToEntity(PaymentDTO paymentDTO) {
Payment payment = modelMapper.map(paymentDTO, Payment.class);
Optional<BankAccount> bankAccountOptional = bankAccountRepository.findById(paymentDTO.getGiverAccount());
if (bankAccountOptional.isEmpty()) {
throw new PaymentValidationException("Payment request invalid: Giver account does not exist.");
}
payment.setGiverAccount(bankAccountOptional.get());
return payment;
}
}

View File

@@ -10,7 +10,7 @@ import java.math.BigDecimal;
public class BalanceDTO {
private Long id;
@NotNull
@DecimalMin(value = "0", inclusive = false)
@DecimalMin(value = "0")
private BigDecimal amount;
@NotNull
private Currency currency;

View File

@@ -3,12 +3,14 @@ package net.kapcake.bankingservice.model.entities;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import net.kapcake.bankingservice.model.domain.BalanceType;
import net.kapcake.bankingservice.model.domain.Currency;
import java.math.BigDecimal;
@Entity
@Accessors(chain = true)
@Getter
@Setter
public class Balance {

View File

@@ -3,11 +3,13 @@ package net.kapcake.bankingservice.model.entities;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import net.kapcake.bankingservice.model.domain.AccountStatus;
import java.util.List;
@Entity
@Accessors(chain = true)
@Getter
@Setter
public class BankAccount {
@@ -25,6 +27,10 @@ public class BankAccount {
@Column(nullable = false)
private String accountName;
@OneToMany
@JoinTable(
joinColumns = {@JoinColumn(name = "BANK_ACCOUNT_ID", nullable = false)},
inverseJoinColumns = {@JoinColumn(name = "BALANCE_ID", nullable = false)}
)
private List<Balance> balances;
@Enumerated(EnumType.STRING)
private AccountStatus status;

View File

@@ -3,6 +3,7 @@ package net.kapcake.bankingservice.model.entities;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import net.kapcake.bankingservice.model.domain.Currency;
import net.kapcake.bankingservice.model.domain.PaymentStatus;
import org.hibernate.annotations.CreationTimestamp;
@@ -11,6 +12,7 @@ import java.math.BigDecimal;
import java.util.Date;
@Entity
@Accessors(chain = true)
@Getter
@Setter
public class Payment {
@@ -32,7 +34,7 @@ public class Payment {
private String communication;
@CreationTimestamp
@Temporal(TemporalType.TIMESTAMP)
@Column(nullable = false)
@Column(nullable = false, updatable = false)
private Date creationDate;
@Enumerated(EnumType.STRING)
private PaymentStatus status;

View File

@@ -0,0 +1,8 @@
package net.kapcake.bankingservice.repositories;
import net.kapcake.bankingservice.model.entities.Balance;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BalanceRepository extends JpaRepository<Balance, Long> {
}

View File

@@ -8,4 +8,6 @@ import java.util.Optional;
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
Optional<List<BankAccount>> findAllByUsers_username(String username);
Optional<BankAccount> findByAccountNumber(String accountNumber);
}

View File

@@ -1,8 +1,9 @@
package net.kapcake.bankingservice.services;
import net.kapcake.bankingservice.model.entities.BankAccount;
import net.kapcake.bankingservice.model.entities.User;
import net.kapcake.bankingservice.converters.BankAccountConverter;
import net.kapcake.bankingservice.model.domain.BankAccountDTO;
import net.kapcake.bankingservice.repositories.BankAccountRepository;
import net.kapcake.bankingservice.security.UserDetailsImpl;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -11,12 +12,15 @@ import java.util.List;
@Service
public class AccountService {
private final BankAccountRepository bankAccountRepository;
private final BankAccountConverter bankAccountConverter;
public AccountService(BankAccountRepository bankAccountRepository) {
public AccountService(BankAccountRepository bankAccountRepository, BankAccountConverter bankAccountConverter) {
this.bankAccountRepository = bankAccountRepository;
this.bankAccountConverter = bankAccountConverter;
}
public List<BankAccount> getAccounts(User user) {
return bankAccountRepository.findAllByUsers_username(user.getUsername()).orElse(Collections.emptyList());
public List<BankAccountDTO> getAccounts(UserDetailsImpl authenticatedUser) {
return bankAccountRepository.findAllByUsers_username(authenticatedUser.getUsername()).orElse(Collections.emptyList())
.stream().map(bankAccountConverter::mapToDTO).toList();
}
}

View File

@@ -1,45 +1,63 @@
package net.kapcake.bankingservice.services;
import net.kapcake.bankingservice.exceptions.PaymentValidationException;
import net.kapcake.bankingservice.model.domain.IbanValidationResponse;
import net.kapcake.bankingservice.converters.PaymentConverter;
import net.kapcake.bankingservice.model.domain.BalanceType;
import net.kapcake.bankingservice.model.domain.PaymentDTO;
import net.kapcake.bankingservice.model.entities.Balance;
import net.kapcake.bankingservice.model.entities.BankAccount;
import net.kapcake.bankingservice.model.entities.Payment;
import net.kapcake.bankingservice.model.entities.User;
import net.kapcake.bankingservice.repositories.BalanceRepository;
import net.kapcake.bankingservice.repositories.BankAccountRepository;
import net.kapcake.bankingservice.repositories.PaymentRepository;
import org.springframework.beans.factory.annotation.Value;
import net.kapcake.bankingservice.security.UserDetailsImpl;
import net.kapcake.bankingservice.validation.PaymentValidator;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Optional;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final BankAccountRepository bankAccountRepository;
private final RestTemplate restTemplate;
private final String validationUrl;
private final BalanceRepository balanceRepository;
private final PaymentValidator paymentValidator;
private final PaymentConverter paymentConverter;
private final TransactionTemplate transactionTemplate;
public PaymentService(PaymentRepository paymentRepository,
BankAccountRepository bankAccountRepository,
RestTemplate restTemplate,
@Value("${banking-service.iban-validation.url}") String validationUrl) {
public PaymentService(PaymentRepository paymentRepository, BankAccountRepository bankAccountRepository,
BalanceRepository balanceRepository, PaymentValidator paymentValidator, PaymentConverter paymentConverter,
PlatformTransactionManager transactionManager) {
this.paymentRepository = paymentRepository;
this.bankAccountRepository = bankAccountRepository;
this.restTemplate = restTemplate;
this.validationUrl = validationUrl;
this.balanceRepository = balanceRepository;
this.paymentValidator = paymentValidator;
this.paymentConverter = paymentConverter;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
public Payment createPayment(User user, Payment payment) {
validatePayment(user, payment);
public PaymentDTO createPayment(UserDetailsImpl authenticatedUser, PaymentDTO paymentDTO) {
Payment payment = paymentConverter.mapToEntity(paymentDTO);
return null;
}
paymentValidator.validate(authenticatedUser.user(), payment);
private void validatePayment(User user, Payment payment) {
if (user.getBankAccounts().stream().noneMatch(bankAccount -> bankAccount.getId().equals(payment.getGiverAccount().getId()))) {
throw new PaymentValidationException("Giver account not owned by authenticated user.");
}
IbanValidationResponse validationResponse = restTemplate.getForObject(validationUrl + payment.getBeneficiaryAccountNumber(), IbanValidationResponse.class);
if (!validationResponse.valid()) {
throw new PaymentValidationException("Beneficiary account not valid: " + validationResponse.messages());
}
BankAccount giverAccount = payment.getGiverAccount();
Optional<BankAccount> beneficiaryAccount = bankAccountRepository.findByAccountNumber(payment.getBeneficiaryAccountNumber());
Payment persistedPayment = transactionTemplate.execute(status -> {
Payment savedPayment = paymentRepository.save(payment);
Balance giverAvailableBalance = giverAccount.getBalances().stream().filter(balance -> BalanceType.AVAILABLE.equals(balance.getType())).findFirst().orElseThrow();
giverAvailableBalance.setAmount(giverAvailableBalance.getAmount().subtract(payment.getAmount()));
balanceRepository.save(giverAvailableBalance);
if (beneficiaryAccount.isPresent()) {
Balance beneficiaryBalance = beneficiaryAccount.get().getBalances().stream().filter(balance -> BalanceType.AVAILABLE.equals(balance.getType())).findFirst().orElseThrow();
beneficiaryBalance.setAmount(beneficiaryBalance.getAmount().add(payment.getAmount()));
balanceRepository.save(beneficiaryBalance);
}
return savedPayment;
});
return paymentConverter.mapToDTO(persistedPayment);
}
}

View File

@@ -0,0 +1,82 @@
package net.kapcake.bankingservice.validation;
import net.kapcake.bankingservice.exceptions.PaymentValidationException;
import net.kapcake.bankingservice.model.domain.BalanceType;
import net.kapcake.bankingservice.model.domain.IbanValidationResponse;
import net.kapcake.bankingservice.model.entities.Balance;
import net.kapcake.bankingservice.model.entities.Payment;
import net.kapcake.bankingservice.model.entities.User;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Component
public class PaymentValidator {
private final RestTemplate restTemplate;
private final String validationUrl;
private final List<String> forbiddenAccounts;
public PaymentValidator(RestTemplate restTemplate,
@Value("${banking-service.validation.iban-validator-url:}") String validationUrl,
@Value("${banking-service.validation.forbidden-accounts:}") List<String> forbiddenAccounts) {
this.restTemplate = restTemplate;
this.validationUrl = validationUrl;
this.forbiddenAccounts = forbiddenAccounts;
}
public void validate(User user, Payment payment) throws PaymentValidationException {
validateGiverAccount(user, payment);
validateBeneficiaryAccount(payment);
validateAccountBalance(payment);
}
private static void validateGiverAccount(User user, Payment payment) {
boolean userOwnsGiverAccount = user.getBankAccounts().stream().anyMatch(bankAccount -> bankAccount.getId().equals(payment.getGiverAccount().getId()));
if (!userOwnsGiverAccount) {
throw new PaymentValidationException("Giver account not owned by authenticated user.");
}
}
private void validateBeneficiaryAccount(Payment payment) {
IbanValidationResponse validationResponse = null;
try {
validationResponse = restTemplate.getForObject(validationUrl + "/" + payment.getBeneficiaryAccountNumber(), IbanValidationResponse.class);
} catch (RestClientException e) {
throw new PaymentValidationException("Beneficiary account could not be validated.");
}
if (validationResponse == null) {
throw new PaymentValidationException("Beneficiary account could not be validated.");
} else if (!validationResponse.valid()) {
throw new PaymentValidationException("Beneficiary account not valid: " + validationResponse.messages());
}
boolean sameGiverAndBeneficiary = payment.getGiverAccount().getAccountNumber().equals(payment.getBeneficiaryAccountNumber());
if (sameGiverAndBeneficiary) {
throw new PaymentValidationException("Beneficiary and giver account are the same.");
}
boolean isBeneficiaryForbidden = forbiddenAccounts.contains(payment.getBeneficiaryAccountNumber());
if (isBeneficiaryForbidden) {
throw new PaymentValidationException("Beneficiary account is forbidden.");
}
}
private void validateAccountBalance(Payment payment) {
List<Balance> availableBalances = payment.getGiverAccount().getBalances().stream().filter(balance -> BalanceType.AVAILABLE.equals(balance.getType())).toList();
if (availableBalances.size() != 1) {
throw new PaymentValidationException("Unable to retrieve available account balance.");
}
Balance balance = availableBalances.get(0);
boolean sameCurrencies = balance.getCurrency().equals(payment.getCurrency());
if (!sameCurrencies) {
throw new PaymentValidationException("Payment and account currency must be the same.");
}
int compareResult = balance.getAmount().compareTo(payment.getAmount());
if (compareResult < 0) {
throw new PaymentValidationException("Available account balance not sufficient.");
}
}
}

View File

@@ -0,0 +1,16 @@
package net.kapcake.bankingservice.validation;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.Collections;
import java.util.List;
@Configuration
@ConfigurationProperties(prefix = "banking-service.validation")
@Data
public class ValidationProperties {
private List<String> forbiddenAccounts = Collections.emptyList();
private String ibanValidatorUrl = "";
}

View File

@@ -9,5 +9,8 @@ spring:
defer-datasource-initialization: true
banking-service:
iban-validation:
url: https://openiban.com/validate/
validation:
forbidden-accounts: >
LU280019400644750000,
LU120010001234567891
iban-validator-url: https://openiban.com/validate

View File

@@ -1,3 +1,4 @@
-- Users
INSERT INTO BANKING_USER (ID, USERNAME, PASSWORD)
VALUES (1, 'user1', '{bcrypt}$2a$10$4dPs2u01F/UBJtQyKRCRLevJACUkDzSdD.4EFKkf0T0qllqtkxw5e');
INSERT INTO BANKING_USER (ID, USERNAME, PASSWORD)
@@ -15,6 +16,7 @@ VALUES (7, 'user7', '{bcrypt}$2a$10$w/CSC.FmoFEbGBJ5lPF3oe0gbZmo3n1wOQK9W50DbDHs
INSERT INTO BANKING_USER (ID, USERNAME, PASSWORD)
VALUES (8, 'user8', '{bcrypt}$2a$10$L1RWBgjH0L.YJG/uyMhviubSxz8C.PnZJlviF6K/iueeDrWO.FSOy');
-- Bank accounts
INSERT INTO BANK_ACCOUNT (ID, ACCOUNT_NAME, ACCOUNT_NUMBER, STATUS)
VALUES (1, 'Personal', 'LU584022594948990503', 'ENABLED');
INSERT INTO BANK_ACCOUNT (ID, ACCOUNT_NAME, ACCOUNT_NUMBER, STATUS)
@@ -56,6 +58,89 @@ VALUES (19, 'Savings', 'LU130189044953642517', 'ENABLED');
INSERT INTO BANK_ACCOUNT (ID, ACCOUNT_NAME, ACCOUNT_NUMBER, STATUS)
VALUES (20, 'Trading', 'LU081651725326393823', 'ENABLED');
-- Balances
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 1, 578.98, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 2, 578.98, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 3, 4135.14, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 4, 4135.14, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 5, 21545.32, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 6, 21545.32, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 7, 201.00, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 8, 201.00, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 9, 984512.23, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 10, 984512.23, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 11, 382690.16, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 12, 382690.16, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 13, 232663.94, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 14, 232663.94, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 15, 421234.41, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 16, 421234.41, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 17, 158052.05, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 18, 158052.05, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 19, 292888.94, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 20, 292888.94, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 21, 168211.44, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 22, 168211.44, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 23, 223757.44, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 24, 223757.44, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 25, 207268.46, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 26, 207268.46, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 27, 326704.48, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 28, 326704.48, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 29, 98377.59, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 30, 98377.59, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 31, 23818.53, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 32, 23818.53, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 33, 252442.60, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 34, 252442.60, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 35, 412747.20, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 36, 412747.20, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 37, 359691.69, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 38, 359691.69, 'EUR', 'END_OF_DAY' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 39, 278739.76, 'EUR', 'AVAILABLE' );
INSERT INTO BALANCE (ID, AMOUNT, CURRENCY, TYPE)
VALUES ( 40, 278739.76, 'EUR', 'END_OF_DAY' );
-- User/Bank account association
INSERT INTO BANK_ACCOUNT_USERS (BANK_ACCOUNT_ID, USER_ID)
VALUES (1, 1);
INSERT INTO BANK_ACCOUNT_USERS (BANK_ACCOUNT_ID, USER_ID)
@@ -110,3 +195,86 @@ INSERT INTO BANK_ACCOUNT_USERS (BANK_ACCOUNT_ID, USER_ID)
VALUES (19, 2);
INSERT INTO BANK_ACCOUNT_USERS (BANK_ACCOUNT_ID, USER_ID)
VALUES (20, 8);
-- Balance / Bank account association
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (1, 1);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (1, 2);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (2, 3);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (2, 4);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (3, 5);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (3, 6);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (4, 7);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (4, 8);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (5, 9);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (5, 10);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (6, 11);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (6, 12);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (7, 13);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (7, 14);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (8, 15);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (8, 16);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (9, 17);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (9, 18);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (10, 19);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (10, 20);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (11, 21);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (11, 22);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (12, 23);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (12, 24);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (13, 25);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (13, 26);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (14, 27);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (14, 28);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (15, 29);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (15, 30);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (16, 31);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (16, 32);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (17, 33);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (17, 34);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (18, 35);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (18, 36);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (19, 37);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (19, 38);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (20, 39);
INSERT INTO BANK_ACCOUNT_BALANCES (BANK_ACCOUNT_ID, BALANCE_ID)
VALUES (20, 40);

View File

@@ -0,0 +1,179 @@
package net.kapcake.bankingservice.validation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.kapcake.bankingservice.config.BankingServiceConfig;
import net.kapcake.bankingservice.exceptions.PaymentValidationException;
import net.kapcake.bankingservice.model.domain.BalanceType;
import net.kapcake.bankingservice.model.domain.Currency;
import net.kapcake.bankingservice.model.domain.IbanValidationResponse;
import net.kapcake.bankingservice.model.entities.Balance;
import net.kapcake.bankingservice.model.entities.BankAccount;
import net.kapcake.bankingservice.model.entities.Payment;
import net.kapcake.bankingservice.model.entities.User;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.response.MockRestResponseCreators;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServiceUnavailable;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@RestClientTest
@ContextConfiguration(classes = {BankingServiceConfig.class})
class PaymentValidatorTest {
private static final String VALIDATION_URL = "https://openiban.com/validate";
private static final String FORBIDDEN_ACCOUNT_1 = "LU789665834233588091";
private static final String FORBIDDEN_ACCOUNT_2 = "LU965552288868196734";
private static final String VALID_BENEFICIARY = "LU785914975828731987";
private static final String INVALID_BENEFICIARY = "LU785914975828731947";
private static final List<String> FORBIDDEN_ACCOUNTS = Arrays.asList(FORBIDDEN_ACCOUNT_1, FORBIDDEN_ACCOUNT_2);
private static final String USERNAME = "username";
private static final String GIVER_ACCOUNT_NUMBER = "LU038359494013886902";
private final RestTemplate restTemplate;
private final PaymentValidator paymentValidator;
private final MockRestServiceServer server;
private final ObjectMapper objectMapper;
private BankAccount bankAccount;
private Payment payment;
private User user;
private Balance balance;
@Autowired
public PaymentValidatorTest(RestTemplate restTemplate, MockRestServiceServer server) {
this.restTemplate = restTemplate;
this.paymentValidator = new PaymentValidator(restTemplate, VALIDATION_URL, FORBIDDEN_ACCOUNTS);
objectMapper = new ObjectMapper();
this.server = server;
}
@BeforeEach
void setUp() throws JsonProcessingException {
user = new User().setId(1L).setUsername(USERNAME);
balance = new Balance().setId(1L).setType(BalanceType.AVAILABLE);
bankAccount = new BankAccount().setId(1L).setBalances(Collections.singletonList(balance));
payment = new Payment();
// Set up valid payment
bankAccount.setAccountNumber(GIVER_ACCOUNT_NUMBER).setUsers(Collections.singletonList(user));
balance.setCurrency(Currency.EUR).setAmount(BigDecimal.valueOf(5.5));
user.setBankAccounts(Collections.singletonList(bankAccount));
payment.setGiverAccount(bankAccount).setCurrency(Currency.EUR).setBeneficiaryAccountNumber(VALID_BENEFICIARY).setAmount(BigDecimal.valueOf(4));
server
.expect(requestTo(VALIDATION_URL + "/" + VALID_BENEFICIARY))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(objectMapper.writeValueAsString(new IbanValidationResponse(true, Collections.emptyList(), VALID_BENEFICIARY)), MediaType.APPLICATION_JSON));
}
@Test
void validate_correctPayment() {
paymentValidator.validate(user, payment);
}
@Test
void validate_invalidGiverAccount() {
user.setBankAccounts(Collections.emptyList());
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Giver account not owned by authenticated user.", paymentValidationException.getMessage());
}
@Test
void validate_invalidBeneficiaryAccount_ibanValidationIssue() {
payment.setBeneficiaryAccountNumber(INVALID_BENEFICIARY);
server.reset();
server
.expect(requestTo(VALIDATION_URL + "/" + INVALID_BENEFICIARY))
.andExpect(method(HttpMethod.GET))
.andRespond(withServiceUnavailable());
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Beneficiary account could not be validated.", paymentValidationException.getMessage());
server.reset();
server
.expect(requestTo(VALIDATION_URL + "/" + INVALID_BENEFICIARY))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess());
paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Beneficiary account could not be validated.", paymentValidationException.getMessage());
}
@Test
void validate_invalidBeneficiaryAccount_invalidIban() throws JsonProcessingException {
payment.setBeneficiaryAccountNumber(INVALID_BENEFICIARY);
server.reset();
server
.expect(requestTo(VALIDATION_URL + "/" + INVALID_BENEFICIARY))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(objectMapper.writeValueAsString(new IbanValidationResponse(false, Collections.singletonList("Invalid"), VALID_BENEFICIARY)), MediaType.APPLICATION_JSON));
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Beneficiary account not valid: [Invalid]", paymentValidationException.getMessage());
}
@Test
void validate_invalidBeneficiaryAccount_giverAndBeneficiarySame() {
bankAccount.setAccountNumber(VALID_BENEFICIARY);
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Beneficiary and giver account are the same.", paymentValidationException.getMessage());
}
@Test
void validate_invalidBeneficiaryAccount_forbiddenAccount() throws JsonProcessingException {
payment.setBeneficiaryAccountNumber(FORBIDDEN_ACCOUNT_1);
server.reset();
server
.expect(requestTo(VALIDATION_URL + "/" + FORBIDDEN_ACCOUNT_1))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(objectMapper.writeValueAsString(new IbanValidationResponse(true, Collections.emptyList(), FORBIDDEN_ACCOUNT_1)), MediaType.APPLICATION_JSON));
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Beneficiary account is forbidden.", paymentValidationException.getMessage());
}
@Test
void validate_invalidAccountBalance_notFound() {
balance.setType(BalanceType.END_OF_DAY);
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Unable to retrieve available account balance.", paymentValidationException.getMessage());
}
@Test
void validate_invalidAccountBalance_differentCurrencies() {
balance.setCurrency(Currency.USD);
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Payment and account currency must be the same.", paymentValidationException.getMessage());
}
@Test
void validate_invalidAccountBalance_insufficientBalance() {
balance.setAmount(BigDecimal.valueOf(2));
PaymentValidationException paymentValidationException = Assertions.assertThrows(PaymentValidationException.class, () -> paymentValidator.validate(user, payment));
Assertions.assertEquals("Available account balance not sufficient.", paymentValidationException.getMessage());
}
}