diff --git a/README.md b/README.md index 637f474b..2bf2df47 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ -# Midas +# MidasFinCore Project repo for the JPMC Advanced Software Engineering Forage program + +# Midas Core System + +## Overview + +**Midas Core** is a key component of the larger **Midas System**, a high-profile fintech platform designed to handle financial transactions with speed, reliability, and integrity. This project is part of a distributed system that integrates real-time transaction processing, data validation, and external API incentivization. + +The goal of Midas Core is to **receive**, **validate**, and **record** incoming financial transactions. It leverages modern backend technologies such as **Spring Boot**, **Apache Kafka**, **SQL databases**, and **REST APIs** to ensure secure, scalable, and efficient processing. + +--- + +## Tech Stack + +- **Java 17** +- **Spring Boot** +- **Apache Kafka** – for receiving streaming financial transaction data +- **PostgreSQL / MySQL** – for transaction validation and persistence +- **External REST APIs** – for handling transaction incentivization +- **Maven / Gradle** – for build and dependency management + +--- + +## Architecture + +Midas Core operates as a service within a microservices architecture: + +1. **Receives transactions** via Kafka topics. +2. **Validates** each transaction against business rules and database records. +3. **Persists** the transaction data to a SQL database. +4. **Communicates** with an external REST API to trigger incentive mechanisms. + +All components are wired using **Spring Boot’s dependency injection**, allowing clean modular development and easier testing. + +--- + +## Getting Started + +### Prerequisites + +- Java 17+ +- Maven or Gradle +- Docker (optional, for local Kafka or DB setup) +- PostgreSQL or MySQL running locally or via Docker + +### Setup + +```bash +# Clone the repository +git clone https://github.com/YOUR-USERNAME/midas-core.git +cd midas-core + +# Build the project +./mvnw clean install + +# Run the application +./mvnw spring-boot:run diff --git a/application.yml b/application.yml index e69de29b..3609de66 100644 --- a/application.yml +++ b/application.yml @@ -0,0 +1,51 @@ +general: + kafka-topic: midas-transactions + +incentive: + api: + url: http://localhost:8080/transaction + +server: + port: 33400 + +spring: + datasource: + url: jdbc:h2:mem:midasdb;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + + kafka: + consumer: + bootstrap-servers: ${spring.embedded.kafka.brokers:localhost:9092} + group-id: midas-consumer-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "com.jpmc.midascore.entity,com.jpmc.midascore.foundation" + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + producer: + bootstrap-servers: ${spring.embedded.kafka.brokers:localhost:9092} + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + + h2: + console: + enabled: true + path: /h2-console + +logging: + level: + org.springframework: INFO + com.jpmc.midascore: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type: TRACE \ No newline at end of file diff --git a/pom.xml b/pom.xml index d1dedfec..3a6afbd7 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,52 @@ 17 + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.kafka + spring-kafka + 3.1.4 + + + com.h2database + h2 + runtime + + + org.springframework.kafka + spring-kafka-test + test + + + org.testcontainers + kafka + 1.19.1 + test + diff --git a/src/main/java/com/jpmc/midascore/MidasCoreApplication.java b/src/main/java/com/jpmc/midascore/MidasCoreApplication.java index 9222581f..26b9ef2e 100644 --- a/src/main/java/com/jpmc/midascore/MidasCoreApplication.java +++ b/src/main/java/com/jpmc/midascore/MidasCoreApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication +@EntityScan("com.jpmc.midascore.entity") // Explicitly scan entity package +@EnableJpaRepositories("com.jpmc.midascore.repository") // Explicitly scan repository package public class MidasCoreApplication { public static void main(String[] args) { diff --git a/src/main/java/com/jpmc/midascore/component/IncentiveService.java b/src/main/java/com/jpmc/midascore/component/IncentiveService.java new file mode 100644 index 00000000..0941fbe8 --- /dev/null +++ b/src/main/java/com/jpmc/midascore/component/IncentiveService.java @@ -0,0 +1,28 @@ +package com.jpmc.midascore.component; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jpmc.midascore.exception.DependencyException; +import com.jpmc.midascore.foundation.Transaction; + +@Component +public class IncentiveService { + + private final RestTemplate restTemplate = new RestTemplate(); + + private final static String INCENTIVE_URL = "http://localhost:8080/incentive"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public float getIncentive(Transaction transaction) throws DependencyException { + try { + String responseEntityStr = restTemplate.postForObject(INCENTIVE_URL, transaction, String.class); + return Float.parseFloat(objectMapper.readTree(responseEntityStr).get("amount").asText()); + } catch (Exception e) { + throw new DependencyException(e.getMessage()); + } + } + +} diff --git a/src/main/java/com/jpmc/midascore/component/TransactionConsumer.java b/src/main/java/com/jpmc/midascore/component/TransactionConsumer.java new file mode 100644 index 00000000..51e677fb --- /dev/null +++ b/src/main/java/com/jpmc/midascore/component/TransactionConsumer.java @@ -0,0 +1,90 @@ +package com.jpmc.midascore.component; + +import com.jpmc.midascore.entity.TransactionRecord; +import com.jpmc.midascore.entity.UserRecord; +import com.jpmc.midascore.exception.DependencyException; +import com.jpmc.midascore.exception.ValidationException; +import com.jpmc.midascore.foundation.Transaction; +import com.jpmc.midascore.repository.TransactionRecordRepository; +import com.jpmc.midascore.repository.UserRepository; + +import java.util.Optional; + +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class TransactionConsumer { + + private final UserRepository userRepository; + + private final TransactionRecordRepository transactionRecordRepository; + + private final IncentiveService incentiveService; + + public TransactionConsumer(UserRepository userRepository, + TransactionRecordRepository transactionRecordRepository, + IncentiveService incentiveService) { + this.userRepository = userRepository; + this.transactionRecordRepository = transactionRecordRepository; + this.incentiveService = incentiveService; + } + + @KafkaListener(topics = "${general.kafka-topic}", groupId = "${spring.kafka.consumer.group-id}") + public void listen(Transaction transaction) { + try { + final UserRecord receiver = validateAndGetReceiver(transaction); + final UserRecord sender = validateAndGetSender(transaction); + final TransactionRecord transactionRecord = new TransactionRecord(sender, receiver, + transaction.getAmount()); + final float incentive = getIncentive(transaction); + receiver.setBalance(receiver.getBalance() + transaction.getAmount() + incentive); + sender.setBalance(sender.getBalance() - transaction.getAmount()); + updateDatabaseInOneTransaction(transactionRecord, sender, receiver); + System.out.println(sender.getName() + " - balance: " + sender.getBalance()); + System.out.println(receiver.getName() + " - balance: " + receiver.getBalance()); + } catch (ValidationException ve) { + System.out.println("no modification to db because of error: " + ve.getMessage()); + } catch (DependencyException de) { + System.out.println("no modification to db because of error: " + de.getMessage()); + } + } + + private float getIncentive(Transaction transaction) throws DependencyException { + return incentiveService.getIncentive(transaction); + } + + @Transactional + private void updateDatabaseInOneTransaction(final TransactionRecord transactionRecord, + final UserRecord sender, final UserRecord receiver) { + transactionRecordRepository.save(transactionRecord); + userRepository.save(sender); + userRepository.save(receiver); + } + + private UserRecord validateAndGetReceiver(final Transaction transaction) throws ValidationException { + final Long recipientId = transaction.getRecipientId(); + Optional userRecord = userRepository.findById(recipientId); + if (!userRecord.isPresent()) { + final String errorMsg = "Recipient not found with id " + recipientId; + throw new ValidationException(errorMsg); + } + return userRecord.get(); + } + + private UserRecord validateAndGetSender(final Transaction transaction) throws ValidationException { + final Long senderId = transaction.getSenderId(); + Optional userRecord = userRepository.findById(senderId); + if (!userRecord.isPresent()) { + final String errorMsg = "Sender not found with id " + senderId; + throw new ValidationException(errorMsg); + } + final UserRecord sender = userRecord.get(); + if (sender.getBalance() < transaction.getAmount()) { + final String errorMsg = "Sender does not have enough balance to complete the transaction"; + throw new ValidationException(errorMsg); + } + return sender; + } +} \ No newline at end of file diff --git a/src/main/java/com/jpmc/midascore/entity/TransactionRecord.java b/src/main/java/com/jpmc/midascore/entity/TransactionRecord.java new file mode 100644 index 00000000..762f5c96 --- /dev/null +++ b/src/main/java/com/jpmc/midascore/entity/TransactionRecord.java @@ -0,0 +1,75 @@ +package com.jpmc.midascore.entity; + +import jakarta.persistence.*; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; + +@Entity +@JsonIdentityInfo( + generator = ObjectIdGenerators.PropertyGenerator.class, + property = "id") +public class TransactionRecord { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "senderId", nullable = false) + private UserRecord sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipientId", nullable = false) + private UserRecord recipient; + + @Column(nullable = false) + private float amount; + + // Default constructor required by JPA + public TransactionRecord() {} + + // All-args constructor for convenience + public TransactionRecord(UserRecord sender, UserRecord recipient, float amount) { + this.sender = sender; + this.recipient = recipient; + this.amount = amount; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public UserRecord getSender() { + return sender; + } + + public void setSender(UserRecord sender) { + this.sender = sender; + } + + public UserRecord getRecipient() { + return recipient; + } + + public void setRecipient(UserRecord recipient) { + this.recipient = recipient; + } + + public float getAmount() { + return amount; + } + + public void setAmount(float amount) { + this.amount = amount; + } + + @Override + public String toString() { + return "Transaction{" + + "id=" + id + + ", senderId=" + (sender != null ? sender.getId() : "null") + + ", recipientId=" + (recipient != null ? recipient.getId() : "null") + + ", amount=" + amount + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/com/jpmc/midascore/entity/UserRecord.java b/src/main/java/com/jpmc/midascore/entity/UserRecord.java index f9606fff..1417d8cc 100644 --- a/src/main/java/com/jpmc/midascore/entity/UserRecord.java +++ b/src/main/java/com/jpmc/midascore/entity/UserRecord.java @@ -1,6 +1,7 @@ package com.jpmc.midascore.entity; import jakarta.persistence.*; +import java.util.List; @Entity public class UserRecord { @@ -15,6 +16,12 @@ public class UserRecord { @Column(nullable = false) private float balance; + @OneToMany(mappedBy = "sender") + private List sentTransactions; + + @OneToMany(mappedBy = "recipient") + private List receivedTransactions; + protected UserRecord() { } @@ -25,7 +32,7 @@ public UserRecord(String name, float balance) { @Override public String toString() { - return String.format("User[id=%d, name='%s', balance='%f'", id, name, balance); + return String.format("User[id=%d, name='%s', balance='%f']", id, name, balance); } public Long getId() { diff --git a/src/main/java/com/jpmc/midascore/exception/DependencyException.java b/src/main/java/com/jpmc/midascore/exception/DependencyException.java new file mode 100644 index 00000000..f9fa7e29 --- /dev/null +++ b/src/main/java/com/jpmc/midascore/exception/DependencyException.java @@ -0,0 +1,9 @@ +package com.jpmc.midascore.exception; + +public class DependencyException extends Exception { + + public DependencyException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/jpmc/midascore/exception/ValidationException.java b/src/main/java/com/jpmc/midascore/exception/ValidationException.java new file mode 100644 index 00000000..deb59710 --- /dev/null +++ b/src/main/java/com/jpmc/midascore/exception/ValidationException.java @@ -0,0 +1,7 @@ +package com.jpmc.midascore.exception; + +public class ValidationException extends Exception { + public ValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/jpmc/midascore/repository/TransactionRecordRepository.java b/src/main/java/com/jpmc/midascore/repository/TransactionRecordRepository.java new file mode 100644 index 00000000..c62cfc71 --- /dev/null +++ b/src/main/java/com/jpmc/midascore/repository/TransactionRecordRepository.java @@ -0,0 +1,8 @@ +package com.jpmc.midascore.repository; + +import com.jpmc.midascore.entity.TransactionRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransactionRecordRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/jpmc/midascore/repository/UserRepository.java b/src/main/java/com/jpmc/midascore/repository/UserRepository.java index 937275b6..48267a7f 100644 --- a/src/main/java/com/jpmc/midascore/repository/UserRepository.java +++ b/src/main/java/com/jpmc/midascore/repository/UserRepository.java @@ -1,8 +1,11 @@ package com.jpmc.midascore.repository; import com.jpmc.midascore.entity.UserRecord; -import org.springframework.data.repository.CrudRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; -public interface UserRepository extends CrudRepository { - UserRecord findById(long id); -} +public interface UserRepository extends JpaRepository { + Optional findByName(String name); + + Optional findById(Long id); +} \ No newline at end of file diff --git a/src/main/java/com/jpmc/midascore/rest/BalanceController.java b/src/main/java/com/jpmc/midascore/rest/BalanceController.java new file mode 100644 index 00000000..8250501b --- /dev/null +++ b/src/main/java/com/jpmc/midascore/rest/BalanceController.java @@ -0,0 +1,32 @@ +package com.jpmc.midascore.rest; + +import java.util.Optional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.jpmc.midascore.entity.UserRecord; +import com.jpmc.midascore.foundation.Balance; +import com.jpmc.midascore.repository.UserRepository; + +@RestController +public class BalanceController { + + private final UserRepository userRepository; + + private static final Balance ZERO_BALANCE = new Balance(0); + + public BalanceController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping("/balance") + public Balance greeting(final Long userId) { + Optional userRecord = userRepository.findById(userId); + if (userRecord.isPresent()) { + return new Balance(userRecord.get().getBalance()); + } else { + return ZERO_BALANCE; + } + } + , +} diff --git a/src/test/java/com/jpmc/midascore/FileLoader.java b/src/test/java/com/jpmc/midascore/FileLoader.java index 69992eb4..3432bd3f 100644 --- a/src/test/java/com/jpmc/midascore/FileLoader.java +++ b/src/test/java/com/jpmc/midascore/FileLoader.java @@ -1,7 +1,7 @@ package com.jpmc.midascore; import org.springframework.stereotype.Component; -import org.testcontainers.shaded.org.apache.commons.io.IOUtils; +import org.apache.commons.io.IOUtils; import java.io.InputStream;