1
\$\begingroup\$

I need some insights on my bank application written with Spring Boot. Specifically on the transactions aspect and anything I missed out.

TransactionEntity.java

package com.samuel.bankapi.models.entities;
import com.samuel.bankapi.enums.StatusType;
import com.samuel.bankapi.enums.TransactionType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@Table(name = "transactions")
public class TransactionEntity {
 @Id
 @GeneratedValue(strategy = GenerationType.UUID)
 private String id;
 @ManyToOne(cascade = CascadeType.MERGE)
 @JoinColumn(name = "sender_id", nullable = false)
 private UserEntity sender;
 @ManyToOne(cascade = CascadeType.MERGE)
 @JoinColumn(name = "reciever_id", nullable = false)
 private UserEntity reciever;
 @Column(nullable = false)
 private double amount;
 @Column(nullable = false)
 @Enumerated(EnumType.STRING)
 private TransactionType transactionType;
 @Column(nullable = false)
 private String description;
 @Column(nullable = false)
 private Date transactionDate;
 @Column(nullable = false)
 @Enumerated(EnumType.STRING)
 private StatusType status;
 private String reference;
 @Transient
 private String transactionPin;
}

TransactionDto

package com.samuel.bankapi.models.dto;
import com.samuel.bankapi.models.entities.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import org.springframework.security.core.userdetails.User;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionDto {
 private String id;
 private String sender;
 private String reciever;
 private double amount;
 private String transactionType;
 private String description;
 private String status;
 private String reference;
 private String transactionPin;
}

TransactionReceiptEntity.java

package com.samuel.bankapi.models.entities;
import java.util.Date;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "transaction_receipts")
public class TransactionReceiptEntity {
 @Id
 @GeneratedValue(strategy = GenerationType.UUID)
 private String id;
 @ManyToOne(cascade = CascadeType.MERGE)
 @JoinColumn(name = "transaction_id", nullable = false)
 private TransactionEntity transaction;
 private String transactionType;
 private Date transactionDate;
 private Double amount;
 private Double previousBalance;
 private Double newBalance;
 @ManyToOne(cascade = CascadeType.MERGE)
 @JoinColumn(name = "user_id", nullable = false)
 private UserEntity user;
 private String description;
 private String status;
 private String reference;
}

TransactionReceiptDto.java

package com.samuel.bankapi.models.dto;
import java.util.Date;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TransactionReceiptDto {
 private String id;
 private TransactionReadDto transaction;
 private String transactionType;
 private Date transactionDate;
 private Double amount;
 private Double previousBalance;
 private Double newBalance;
 private UserDto user;
 private String description;
 private String status;
 private String reference;
}

TransactionReadDto.java

package com.samuel.bankapi.models.dto;
import com.samuel.bankapi.models.entities.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransactionReadDto {
 private String id;
 private UserDto sender;
 private UserDto reciever;
 private double amount;
 private String transactionType;
 private String description;
 private String transactionDate;
 private String status;
 private String reference;
 private String transactionPin;
}

TransactionAuditEntity.java

package com.samuel.bankapi.models.entities;
import com.samuel.bankapi.enums.ActionType;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Builder
@Table(name = "transaction_audits")
public class TransactionAuditEntity {
 @Id
 @GeneratedValue(strategy = GenerationType.UUID)
 private String id;
 @JoinColumn(name = "transaction_id", nullable = false)
 @ManyToOne(cascade = CascadeType.MERGE)
 private TransactionEntity transaction;
 @Type(JsonType.class)
 @Column(name = "old_data", columnDefinition = "jsonb")
 private String oldData; // Stored as JSON string
 @Type(JsonType.class)
 @Column(name = "new_data", columnDefinition = "jsonb")
 private String newData; // Stored as JSON string
 @Enumerated(EnumType.STRING)
 @Column(nullable = false)
 private ActionType action;
 @JoinColumn(name = "performed_by", nullable = false)
 @ManyToOne(cascade = CascadeType.MERGE)
 private UserEntity performedBy;
 @Column(name = "performed_at", nullable = false)
 private Date performedAt;
}

TransactionCtrl.java

package com.samuel.bankapi.controllers;
import com.samuel.bankapi.mappers.Mapper;
import com.samuel.bankapi.models.dto.TransactionDto;
import com.samuel.bankapi.models.dto.TransactionReadDto;
import com.samuel.bankapi.models.dto.UserDto;
import com.samuel.bankapi.models.dto.VerifyUserDto;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.payload.TransactionMessage;
import com.samuel.bankapi.producer.KafkaJsonProducer;
import com.samuel.bankapi.services.TransactionService;
import com.samuel.bankapi.services.UserService;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/transactions")
public class TransactionCtrl {
 @Autowired
 TransactionService transactionService;
 @Autowired
 private KafkaJsonProducer kafkaJsonProducer;
 @Autowired
 UserService userService;
 @Autowired
 @Qualifier("transactionMapper")
 Mapper<TransactionEntity, TransactionDto> transactionMapper;
 @Autowired
 @Qualifier("transactionReadMapper")
 Mapper<TransactionEntity, TransactionReadDto> transactionReadMapper;
 @Autowired
 Mapper<UserEntity, VerifyUserDto> userMapper;
 @GetMapping("/verify-and-get-user/{accountNumber}")
 public ResponseEntity<VerifyUserDto> verifyAndGetUser(@PathVariable String accountNumber) {
 if (userService.isExists(accountNumber)) {
 System.out.println("User exists: " + accountNumber);
 UserEntity userEntity = userService.getUser(accountNumber);
 VerifyUserDto verifyUserDto = userMapper.mapTo(userEntity);
 return new ResponseEntity<>(verifyUserDto, HttpStatus.OK);
 } else {
 return new ResponseEntity<>(HttpStatus.NOT_FOUND);
 }
 }
 @GetMapping("/verify-balance/{amount}")
 public ResponseEntity<?> verifyBalance(@PathVariable double amount) {
 UserEntity userEntity = userService.getCurrentUser();
 if (transactionService.isBalanceSufficient(userEntity, amount)) {
 return new ResponseEntity<>(true, HttpStatus.OK);
 } else {
 return new ResponseEntity<>(false, HttpStatus.FORBIDDEN);
 }
 }
 @PostMapping("")
 // @Transactional
 public ResponseEntity<?> createTransaction(@RequestBody TransactionDto transactionDto) {
 try {
 TransactionEntity transactionEntity = transactionMapper.mapFrom(transactionDto);
 TransactionEntity createdTransactionEntity = transactionService.createTransaction(transactionEntity);
 TransactionDto createdTransactionDto = transactionMapper.mapTo(createdTransactionEntity);
 TransactionMessage transactionMessage = new TransactionMessage();
 transactionMessage.setTransactionType(createdTransactionDto.getTransactionType());
 transactionMessage.setTransactionDescription(createdTransactionDto.getDescription());
 transactionMessage.setTransactionAmount(createdTransactionDto.getAmount());
 transactionMessage.setTransactionDate(createdTransactionEntity.getTransactionDate());
 UserEntity sender = userService.getUserById(createdTransactionDto.getSender());
 UserEntity receiver = userService.getUserById(createdTransactionDto.getReciever());
 VerifyUserDto senderDto = userMapper.mapTo(sender);
 VerifyUserDto receiverDto = userMapper.mapTo(receiver);
 transactionMessage.setSender(senderDto);
 transactionMessage.setReceiver(receiverDto);
 kafkaJsonProducer.sendMessage(transactionMessage);
 return new ResponseEntity<>(createdTransactionDto, HttpStatus.CREATED);
 } catch (Exception e) {
 return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
 }
 }
 @GetMapping("")
 public ResponseEntity<?> getAllTransactions() {
 try {
 List<TransactionEntity> transactionEntities = transactionService.getTransactions();
 List<TransactionReadDto> transactionDtos = transactionEntities.stream().map(transactionReadMapper::mapTo).toList();
 return new ResponseEntity<>(transactionDtos, HttpStatus.OK);
 } catch (Exception e) {
 return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
 }
 }
}

TransactionReceiptCtrl.java

package com.samuel.bankapi.controllers;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.samuel.bankapi.mappers.Mapper;
import com.samuel.bankapi.models.dto.TransactionReceiptDto;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.services.TransactionReceiptService;
import com.samuel.bankapi.services.TransactionService;
import com.samuel.bankapi.services.UserService;
@RestController
@RequestMapping("/api/transactions/receipts")
public class TransactionReceiptCtrl {
 @Autowired
 private TransactionReceiptService transactionReceiptService;
 @Autowired
 private TransactionService transactionService;
 @Autowired
 private UserService userService;
 @Autowired
 private Mapper<TransactionReceiptEntity, TransactionReceiptDto> transactionReceiptMapper;
 @GetMapping("")
 public ResponseEntity<?> getReceipts() {
 try {
 List<TransactionReceiptEntity> receipts = transactionReceiptService.getReceipts();
 List<TransactionReceiptDto> receiptDtos = receipts.stream().map(transactionReceiptMapper::mapTo).toList();
 return new ResponseEntity<>(receiptDtos, HttpStatus.OK);
 } catch (Exception e) {
 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
 }
 }
 @GetMapping("/{id}/{userId}")
 public ResponseEntity<?> getReceipt(@PathVariable String id, @PathVariable String userId) {
 try {
 TransactionEntity transaction = transactionService.getTransaction(id);
 if (transaction == null) {
 return new ResponseEntity<>(HttpStatus.NOT_FOUND);
 }
 UserEntity user = userService.getUserById(userId);
 if (user == null) {
 return new ResponseEntity<>(HttpStatus.NOT_FOUND);
 }
 TransactionReceiptEntity receipt = transactionReceiptService.getReceipt(transaction, user);
 TransactionReceiptDto receiptDto = transactionReceiptMapper.mapTo(receipt);
 return new ResponseEntity<>(receiptDto, HttpStatus.OK);
 } catch (Exception e) {
 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
 }
 }
}

This is were would appreciate more feedback

TransactionService

package com.samuel.bankapi.services;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.samuel.bankapi.enums.ActionType;
import com.samuel.bankapi.exceptions.TransactionException;
import com.samuel.bankapi.exceptions.UserException;
import com.samuel.bankapi.enums.StatusType;
import com.samuel.bankapi.models.UserPrincipal;
import com.samuel.bankapi.models.entities.TransactionAuditEntity;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.repositories.TransactionReceiptRepo;
import com.samuel.bankapi.repositories.TransactionRepo;
import com.samuel.bankapi.repositories.UserRepo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.StreamSupport;
@Service
public class TransactionService {
 @Autowired
 private UserRepo userRepo;
 @Autowired
 private ObjectMapper objectMapper;
 @Autowired
 private TransactionRepo transactionRepo;
 @Autowired
 private TransactionAuditService transactionAuditService;
 @Autowired
 private UserService userService;
 @Autowired
 private TransactionReceiptRepo transactionReceiptRepo;
 public boolean isBalanceSufficient(UserEntity userEntity, double amount) {
 // check if the userEntity balance is greater than the amount
 return userEntity.getBalance() >= amount;
 }
 private boolean usersActive(TransactionEntity createdTransactionEntity) {
 if (!createdTransactionEntity.getSender().isActive() || !createdTransactionEntity.getReciever().isActive()) {
 throw new TransactionException.InvalidTransactionException("Sender or receiver not an active account");
 }
 return true;
 }
 private boolean validateTransactionInput(TransactionEntity transactionEntity) {
 // check if the sender and receiver are the same
 if (transactionEntity.getSender().getId().equals(transactionEntity.getReciever().getId())) {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.InvalidTransactionException("Sender and receiver cannot be the same");
 }
 // check if the amount is greater than 0
 if (transactionEntity.getAmount() < 100) {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.InvalidTransactionException("Amount must be greater than 100");
 }
 // check if the sender has sufficient balance
 if (!isBalanceSufficient(transactionEntity.getSender(), transactionEntity.getAmount())) {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.InvalidTransactionException("Insufficient funds");
 }
 return true;
 }
 private boolean validateTransactionPin(TransactionEntity transactionEntity) {
 // get the current logged in user
 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
 UserEntity userEntity = userRepo.findByUsername(principal.getUsername())
 .orElseThrow(() -> new UserException.UserNotFoundException("User not found"));
 if (userEntity.getUserRole().getRoleName().equals("user")) {
 // check if the transaction pin is correct
 BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
 if (!encoder.matches(transactionEntity.getTransactionPin(), userEntity.getTransactionPin())) {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.InvalidCredentials("Invalid transaction pin");
 }
 }
 return true;
 }
 private Map<String, Double> processTransaction(TransactionEntity createdTransactionEntity) {
 // Update the sender's and receiver's balances
 UserEntity sender = createdTransactionEntity.getSender();
 UserEntity receiver = createdTransactionEntity.getReciever();
 double updatedSenderBalance = userService.decreaseBalance(sender.getId(), createdTransactionEntity.getAmount());
 double updatedReceiverBalance = userService.increaseBalance(receiver.getId(), createdTransactionEntity.getAmount());
 // Update the transaction status to COMPLETED
 createdTransactionEntity.setStatus(StatusType.COMPLETED);
 transactionRepo.save(createdTransactionEntity);
 Map<String, Double> balances = new HashMap<>();
 balances.put("senderBalance", updatedSenderBalance);
 balances.put("receiverBalance", updatedReceiverBalance);
 return balances;
 }
 public TransactionEntity createTransaction(TransactionEntity transactionEntity) throws JsonProcessingException {
 // Initialize transaction
 initializeTransaction(transactionEntity);
 System.out.println("initialized successfully");
 // Log audit for transaction initiation
 logTransactionAudit(transactionEntity, null, objectMapper.writeValueAsString(transactionEntity), ActionType.CREATE);
 System.out.println("log transaction audit");
 // Validate transaction input
 validateTransactionInputAndLogAudit(transactionEntity);
 System.out.println("validated transaction input");
 // Authenticate transaction
 authenticateTransactionAndLogAudit(transactionEntity);
 System.out.println("authenticated transaction");
 // Pre-process transaction
 preProcessTransactionAndLogAudit(transactionEntity);
 System.out.println("pre-processed transaction");
 // Process transaction
 processTransactionAndLogAudit(transactionEntity);
 System.out.println("processed transaction");
 transactionEntity.setTransactionDate(new Date());
 // create two transaction receipts for the sender and receiver
 TransactionReceiptEntity senderReceipt = TransactionReceiptEntity.builder()
 .transaction(transactionEntity)
 .transactionType("DEBIT")
 .transactionDate(transactionEntity.getTransactionDate())
 .amount(transactionEntity.getAmount())
 .previousBalance(transactionEntity.getSender().getBalance() +
 transactionEntity.getAmount())
 .newBalance(transactionEntity.getSender()
 .getBalance())
 .user(transactionEntity.getSender())
 .description("Debit transaction")
 .status("COMPLETED")
 .reference(transactionEntity.getId())
 .build();
 transactionReceiptRepo.save(senderReceipt);
 TransactionReceiptEntity receiverReceipt = TransactionReceiptEntity.builder()
 .transaction(transactionEntity)
 .transactionType("CREDIT")
 .transactionDate(transactionEntity.getTransactionDate())
 .amount(transactionEntity.getAmount())
 .previousBalance(transactionEntity.getReciever().getBalance() -
 transactionEntity.getAmount())
 .newBalance(transactionEntity.getReciever()
 .getBalance())
 .user(transactionEntity.getReciever())
 .description("Credit transaction")
 .status("COMPLETED")
 .reference(transactionEntity.getId())
 .build();
 transactionReceiptRepo.save(receiverReceipt);
 return transactionEntity;
 }
 private void initializeTransaction(TransactionEntity transactionEntity) {
 System.out.println("transactionEntity: " + transactionEntity);
 transactionEntity.setTransactionDate(new Date());
 transactionEntity.setStatus(StatusType.PENDING);
 transactionRepo.save(transactionEntity);
 transactionEntity.setStatus(StatusType.INITIATED);
 }
 private void logTransactionAudit(TransactionEntity transactionEntity, String oldData, String newData, ActionType action) throws JsonProcessingException {
 TransactionAuditEntity transactionAuditEntity = TransactionAuditEntity.builder()
 .transaction(transactionEntity)
 .oldData(oldData)
 .newData(newData)
 .action(action)
 .performedAt(new Date())
 .performedBy(transactionEntity.getSender())
 .build();
 transactionAuditService.createTransactionAudit(transactionAuditEntity);
 }
 private void validateTransactionInputAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "INITIATED")),
 objectMapper.writeValueAsString(Map.of("status", "VALIDATING")),
 ActionType.VALIDATE);
 if (validateTransactionInput(transactionEntity)) {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "VALIDATING")),
 objectMapper.writeValueAsString(Map.of("status", "VALIDATED")),
 ActionType.VALIDATE);
 }
 }
 private void authenticateTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "VALIDATED")),
 objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATING")),
 ActionType.AUTHENTICATE);
 if (validateTransactionPin(transactionEntity)) {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATING")),
 objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATED")),
 ActionType.AUTHENTICATE);
 }
 }
 private void preProcessTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
 boolean isTransactionReadyForProcessing = isBalanceSufficient(transactionEntity.getSender(), transactionEntity.getAmount()) && usersActive(
 transactionEntity
 );
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "AUTHENTICATED")),
 objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSING")),
 ActionType.PRE_PROCESS);
 if (isTransactionReadyForProcessing) {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSING")),
 objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSED")),
 ActionType.PRE_PROCESS);
 } else {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.InvalidTransactionException("Transaction not ready for processing");
 }
 }
 private void processTransactionAndLogAudit(TransactionEntity transactionEntity) throws JsonProcessingException {
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "PRE-PROCESSED")),
 objectMapper.writeValueAsString(Map.of("status", "PROCESSING")),
 ActionType.PROCESS);
 try {
 Map<String, Double> balances = processTransaction(transactionEntity);
 logTransactionAudit(transactionEntity,
 objectMapper.writeValueAsString(Map.of("status", "PROCESSING")),
 objectMapper.writeValueAsString(Map.of("status", "PROCESSED", "senderBalance", balances.get("senderBalance"), "receiverBalance", balances.get("receiverBalance"))),
 ActionType.PROCESS);
 } catch (Exception e) {
 transactionEntity.setStatus(StatusType.FAILED);
 transactionRepo.save(transactionEntity);
 throw new TransactionException.BadTransaction("An error occurred during transaction");
 }
 }
 public List<TransactionEntity> getTransactions() {
 return StreamSupport.stream(transactionRepo.findAllByOrderByTransactionDateDesc().spliterator(), false).toList();
 }
 public TransactionEntity getTransaction(String id) {
 return transactionRepo.findById(id).orElse(null);
 }
}

TransactionAuditService.java

package com.samuel.bankapi.services;
import com.samuel.bankapi.models.entities.TransactionAuditEntity;
import com.samuel.bankapi.repositories.TransactionAuditRepo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TransactionAuditService {
 @Autowired
 TransactionAuditRepo transactionAuditRepo;
 public TransactionAuditEntity createTransactionAudit(TransactionAuditEntity transactionAuditEntity) {
 return transactionAuditRepo.save(transactionAuditEntity);
 }
}

TransactionReceiptService.java

package com.samuel.bankapi.services;
import java.util.List;
import java.util.stream.StreamSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.samuel.bankapi.models.entities.TransactionEntity;
import com.samuel.bankapi.models.entities.TransactionReceiptEntity;
import com.samuel.bankapi.models.entities.UserEntity;
import com.samuel.bankapi.repositories.TransactionReceiptRepo;
@Service
public class TransactionReceiptService {
 @Autowired
 private TransactionReceiptRepo transactionReceiptRepo;
 public List<TransactionReceiptEntity> getReceipts() {
 return StreamSupport.stream(transactionReceiptRepo.findAll().spliterator(), false).toList();
 }
 public TransactionReceiptEntity getReceipt(TransactionEntity transaction, UserEntity user) {
 return transactionReceiptRepo.findByTransactionAndUser(transaction, user);
 }
}
asked Nov 4, 2024 at 16:25
\$\endgroup\$
4
  • \$\begingroup\$ I think this question will benefit from a more elaborate description of the design (especially if you would like to get feedback on your design). Let's say a new transaction comes, what would a typical flow look like? \$\endgroup\$ Commented Nov 4, 2024 at 19:13
  • \$\begingroup\$ stackoverflow.com/q/3730019/4616087 \$\endgroup\$ Commented Nov 5, 2024 at 13:37
  • \$\begingroup\$ I would also avoid using java.util.Date in favor of the classes from the java.time package. \$\endgroup\$ Commented Nov 5, 2024 at 13:39
  • \$\begingroup\$ Never use floating point numbers to represent money. The math is not accurate. Use BigDecimal instead. \$\endgroup\$ Commented Nov 6, 2024 at 6:36

1 Answer 1

3
\$\begingroup\$

Transactions - don't Reinvent the Wheel

Your implementation of money transfer between accounts is not atomic, there's nothing in the code that will prevent two concurrent threads from updating simultaneously the balance of the same account.

Instead, you should be using database transactions, which are sequences of operations which either succeeds or fails atomically (don't conflate with financial transactions you're trying to model). That's the tool you need to master to ensure data integrity in your relational database.

And you should also learn how to commit and rollback transactions with JDBC and with a JPA implementation such as Hibernate. You'll need all these if you wish to understand what Spring Data JPA is doing behind the scenes.

One might think they are wouldn't be dealing with this low-level stuff if they are using Spring Data JPA, but that's not true.

In terms of code, all that you need is only to define a two-line method updating the two accounts annotated with @Transactional. That's it.

Under that hood via the means of AOP Spring will create an EntityManager instance for this method and start a new transaction which will be committed after the method exits.

If you want to learn more, that's your homework, there's plenty of material on the topic

Exception-handling / Swallowed Exceptions

There are several occurrences of the following pattern in your controllers:

try {
 // some logic
} catch (Exception e) {
 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}

This approach is detrimental, because by doing so you're swallowing any type of exception, hiding potential RuntimeExceptions caused by bugs. And you don't want this to happen, instead you want to log such cases in order to be able to investigate them.

I see some of the methods you're invoking within these try statements could throw business exceptions.

That is not the right way to handle business exceptions, either. You should choose the most appropriate response status code for each custom exception, rather than returning HTTP status 500, "Internal Server Error", which indicates an unexpected condition on the server side and is not suitable for describing application-specific exceptions.

By the way, any uncaught exception in a Spring controller will result in an API response with 500 "Internal Server Error" status by default

The proper way to handle exceptions thrown in controllers (and other layers on which controllers depend) when you're doing Spring, is to use @ControllerAdvice. It allows you to create a global exception-handler, generating appropriate HTTP-responses based on the exception types.

Here's a quick example:

@ControllerAdvice
public class GlobalExceptionHandler {
 private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
 // Handlers for each Business Exception producing appropriate responses
 
 @ExceptionHandler(Exception.class)
 ResponseEntity<String> logNonBusiness(Exception e) {
 LOGGER.error("Unexpected Non-Business Exception: ", e);
 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
 }
}

And since you're already using Lombok, instead of defining logger manually, you can annotate this class with @Slf4j which will generate a static logger for you.

The aspect behind the @ControllerAdvice will always pick the most specific handler from matching the thrown exception. And only when there's no other matches, it will resort to the handler-method for Exception.class.

Logging

By default, Spring Boot comes with logging facade SLF4J and Logback logging framework. You don't need to add any extra dependencies in order to try out the code I have shown above.

And a preconfigured default console appender is already available to you out-of-the-box. There's no justification to use System.out.println instead of proper logging.

Input Validation

Basic validation should happen in the system boundary, in your DTOs. There's no point in propagating invalid data (e.g. : transaction amount is negative or less than minimal amount, sender and recipient accounts are the same). Instead of creating entities based on invalid data and calling services, you should throw and reply with 400 bad request right away.

You can make use Jakarta Bean Validation to validate the parameters of incoming DTOs.

Naming

Ctrl - isn't a widely recognized abbreviation.

It doesn't make this controller names more readeable TransactionReceiptCtrl, TransactionCtrl. By skimping on characters, you're only adding more cognitive load.

StatusType is not a very descriptive name for a standalone type. TransactionStatus will communicate its intent better.

Data Types

Torben and Marvin also shared their thoughts on this, I suggest checking their comments

  • java.util.Date -> java.time.Instant

Don't use legacy date-time classes, and it's still there only for backward compatibility reasons.

java.util.Date was superseded java.time.Instant which represents a specific moment on the timeline in UTC.

And all ORM frameworks support Java 8 date-time classes for quite a while, there's no reason to use java.util.Date or java.sql.Date.

  • Monetary values

Floating point arithmetic is inaccurate, any operation on double values might result in a precision loss.

Although, floating point values can be persisted into database (databases have their equivalents of floating point types) or serialized for sending over network without loss of accuracy; and in the code you presented, you're not performing calculations on these double values, ultimately you want to do so and double is unsuitable for that purpose.

To represent monetary values you should be using either BigDecimal or custom type encapsulating allocation logic and rounding rules (so that no cents are lost, instead, one party receives an extra cent while the fractions in the amounts received by others are dropped, keeping the total amount unchanged) as Martin Fowler suggested decades ago in his book "Patterns of Enterprise Application Architecture".

mdfst13
22.4k6 gold badges34 silver badges70 bronze badges
answered Nov 9, 2024 at 2:17
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.