From 7b6616113b7d3bc5faeca9a81f627c4aff8026f6 Mon Sep 17 00:00:00 2001 From: Shashank Chandel <21042676+shashankch@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:27:46 +0530 Subject: [PATCH] feat(domain): hardened entity model, rich domain behavior, BigDecimal financials, and constructor injection (Phase 2A) - Replaced Double with BigDecimal (precision = 19, scale = 4) for monetary fields in User and Transaction entities - Implemented rich domain methods User.debit() and User.credit() encapsulating balance invariants - Added audit timestamps (@CreationTimestamp, @UpdateTimestamp) and optimistic locking (@Version) to User - Added TransactionStatus and TransactionType enums - Added JPA @ManyToOne relationships between Transaction and User with denormalized UPI strings - Added UUID referenceId auto-generation on Transaction (@PrePersist) - Refactored UserService, TransactionService, UserController, and TransactionController to constructor injection - Added unit test suite for User domain logic and Transaction reference ID (UserTest, TransactionTest) - Added ADR-001 (BigDecimal), ADR-002 (Constructor injection), ADR-003 (Rich Domain Model) - Updated ARCHITECTURE.md, ROADMAP.md, CHANGELOG.md, and README.md --- CHANGELOG.md | 10 ++ README.md | 10 +- docs/ADR.md | 84 +++++++++++++---- docs/ARCHITECTURE.md | 21 +++++ docs/ROADMAP.md | 4 +- pom.xml | 15 ++- .../controller/TransactionController.java | 8 +- .../payflow/controller/UserController.java | 11 ++- .../java/com/payflow/entity/Transaction.java | 92 +++++++++++-------- .../com/payflow/entity/TransactionStatus.java | 5 + .../com/payflow/entity/TransactionType.java | 5 + src/main/java/com/payflow/entity/User.java | 90 +++++++++--------- .../payflow/repository/UserRepository.java | 3 +- .../payflow/service/TransactionService.java | 10 +- .../java/com/payflow/service/UserService.java | 13 +-- .../com/payflow/entity/TransactionTest.java | 20 ++++ .../java/com/payflow/entity/UserTest.java | 57 ++++++++++++ 17 files changed, 336 insertions(+), 122 deletions(-) create mode 100644 src/main/java/com/payflow/entity/TransactionStatus.java create mode 100644 src/main/java/com/payflow/entity/TransactionType.java create mode 100644 src/test/java/com/payflow/entity/TransactionTest.java create mode 100644 src/test/java/com/payflow/entity/UserTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b47f6ec..162c439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Phase 2A Entity Model Hardening & Rich Domain: + - Replaced `Double` primitives with `BigDecimal` (`precision = 19, scale = 4`) across `User` and `Transaction` entities. + - Implemented Rich Domain methods (`User.debit()`, `User.credit()`) encapsulating balance invariants and state validation. + - Added audit timestamps (`createdAt`, `updatedAt`) and optimistic locking (`@Version version`) support to `User`. + - Added `TransactionStatus` (`INITIATED`, `COMPLETED`, `FAILED`, `REFUNDED`) and `TransactionType` (`TRANSFER`, `REFUND`) enums. + - Added JPA `@ManyToOne` foreign key relationships between `Transaction` and `User` entities with denormalized UPI strings. + - Added UUID `referenceId` auto-generation (`@PrePersist`) on `Transaction`. + - Refactored all services (`UserService`, `TransactionService`) and controllers (`UserController`, `TransactionController`) to use constructor injection. + - Unit test suite for `User` domain logic and `Transaction` reference ID auto-generation (`UserTest`, `TransactionTest`). + - Architectural Decision Records: `ADR-001` (BigDecimal), `ADR-002` (Constructor injection), `ADR-003` (Rich Domain Model). - Project scaffolding: MIT `LICENSE`, `CHANGELOG.md`, `.editorconfig`. - Architectural Decision Records log (`docs/ADR.md`) and engineering standards guide (`docs/CONVENTIONS.md`). - Spotless code formatting plugin (`com.diffplug.spotless:spotless-maven-plugin`) integrated into Maven build. diff --git a/README.md b/README.md index 168baaf..80a7915 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,18 @@ The project is evolving through a phased implementation roadmap. See the full pl |-------|-------------|--------| | **Phase 0** | Baseline REST API — User & Transaction CRUD with H2 in-memory storage | ✅ Complete | | **Phase 1** | Project hygiene — Spotless, Checkstyle, GitHub Actions CI | ✅ Complete | -| **Phase 2** | Domain model hardening, DTO layer, validation, error handling | ⬜ Not Started | -| **Phase 3+** | Concurrency control, Flyway, security, observability, and more | ⬜ Not Started | +| **Phase 2A** | Domain model hardening — `BigDecimal` financials, rich domain methods, audit timestamps | ✅ Complete | +| **Phase 2B+** | DTO layer, validation, error handling, concurrency control, security, observability | 🔄 In Progress | --- ## Implemented Features -- **REST API**: Basic CRUD endpoints for User registration and Transaction creation. +- **Rich Domain Model**: Entities encapsulate domain invariants (`User.debit()`, `User.credit()`) and balance validation. +- **Financial Precision**: All monetary values mapped using `BigDecimal` (`precision = 19, scale = 4`) to prevent floating-point rounding errors. +- **Entity Hardening**: Audit timestamps (`createdAt`, `updatedAt`), optimistic locking (`@Version`), JPA `@ManyToOne` foreign key constraints, UUID reference IDs, and transaction status/type enums. +- **Constructor Injection**: Enforced across all service and controller components for immutability and testability. +- **REST API**: CRUD endpoints for User registration and Transaction creation. - **Layered Architecture**: Controller → Service → Repository pattern with Spring Data JPA. - **In-Memory Database**: H2 with auto-generated schema for zero-dependency local development. - **Code Quality**: Spotless (Eclipse formatter) + Checkstyle enforced at Maven `validate` phase. diff --git a/docs/ADR.md b/docs/ADR.md index 2e3d2ec..9b0537d 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -10,36 +10,86 @@ ADRs document significant technical decisions, along with their context, rationa | ADR # | Title | Date | Status | | :--- | :--- | :--- | :--- | -| *No ADRs recorded yet. ADRs will be appended as features are implemented.* | | | | +| [ADR-001](#adr-001-use-bigdecimal-for-all-monetary-values) | Use BigDecimal for all monetary values | 2026-08-01 | Accepted | +| [ADR-002](#adr-002-constructor-injection-over-field-injection) | Constructor injection over field injection | 2026-08-01 | Accepted | +| [ADR-003](#adr-003-rich-domain-model-over-anemic-domain-model) | Rich Domain Model over Anemic Domain Model | 2026-08-01 | Accepted | --- -## ADR Template +### ADR-001: Use BigDecimal for all monetary values -When creating a new Architecture Decision Record, use the following template: +**Date**: 2026-08-01 +**Status**: Accepted +**Phase**: Phase 2A -```markdown -### ADR-XXX: [Short Title of Decision] +#### Context & Problem Statement +Floating-point primitive types (`double`, `float`) use IEEE 754 binary representation, which cannot precisely represent base-10 decimals (e.g., `0.1 + 0.2 = 0.30000000000000004`). In financial applications, accumulated rounding errors compromise ledger integrity and result in monetary discrepancy. + +#### Considered Options +1. **IEEE 754 Floating-Point (`Double`/`float`)**: Fast, built-in, but causes imprecise rounding error. +2. **Integer Cent/Sub-unit Amounts (`Long` cents)**: Precise, but awkward when dealing with fractional currency fractions or dynamic currency precision. +3. **Java `BigDecimal`**: Arbitrary-precision signed decimal numbers, explicitly suited for financial calculations. + +#### Decision Outcome +Chosen Option: **Java `BigDecimal`** + +##### Rationale +`BigDecimal` provides exact precision representation for decimal currency values with configurable scale (`precision = 19, scale = 4` in database mapping) and explicit rounding modes (`RoundingMode.HALF_EVEN`). + +#### Consequences +- **Positive**: Zero floating-point rounding errors in monetary balance arithmetic and ledger entries. +- **Negative / Trade-offs**: Slightly higher memory overhead and minor performance cost compared to native double primitives. +- **Risks & Mitigations**: Always specify scale and explicit rounding mode (`RoundingMode.HALF_EVEN`) when performing division or scale adjustments. + +--- + +### ADR-002: Constructor injection over field injection + +**Date**: 2026-08-01 +**Status**: Accepted +**Phase**: Phase 2A + +#### Context & Problem Statement +Field injection via `@Autowired` tightly couples Spring components to the Spring DI container, prevents `final` immutable fields, hides component dependencies, and makes unit testing difficult without starting Spring contexts or using reflection. + +#### Considered Options +1. **Field Injection (`@Autowired private Service service`)**: Convenient syntax, but hides dependencies and inhibits immutability/testability. +2. **Setter Injection**: Allows optional dependencies, but enables mutable component state post-construction. +3. **Constructor Injection**: Explicitly declares required dependencies as `final` parameters. + +#### Decision Outcome +Chosen Option: **Constructor Injection** + +##### Rationale +Constructor injection enforces immutability (`final` fields), guarantees all required dependencies are provided at instantiation time, simplifies unit testing without Spring context spinners, and aligns with Spring Framework best practices. + +#### Consequences +- **Positive**: Clean component testability, immutability guarantees, static dependency verification at compile time. +- **Negative / Trade-offs**: Slightly more boilerplate constructor code (mitigated by explicit standard constructors). +- **Risks & Mitigations**: Circular dependency detection occurs at startup (which is desirable as it indicates architectural smell). + +--- + +### ADR-003: Rich Domain Model over Anemic Domain Model -**Date**: YYYY-MM-DD -**Status**: [Proposed | Accepted | Superseded | Deprecated] -**Phase**: [Phase X] +**Date**: 2026-08-01 +**Status**: Accepted +**Phase**: Phase 2A #### Context & Problem Statement -What is the technical problem we are solving? What constraints or requirements impact this decision? +Anemic domain models treat JPA entities as simple data bags with getters and setters, scattering business invariants (such as non-negative balance checks and debit rules) across multiple service classes. #### Considered Options -1. **Option 1**: Description -2. **Option 2**: Description +1. **Anemic Domain Model**: Entities hold only state; services perform all business logic and validations. +2. **Rich Domain Model**: Entities encapsulate state alongside domain behaviors and invariant checks (`debit()`, `credit()`). #### Decision Outcome -Chosen Option: **[Option Name]** +Chosen Option: **Rich Domain Model** ##### Rationale -Why was this option chosen over the alternatives? +Rich domain entities encapsulate domain invariants directly within entity boundaries (`User.debit()`, `User.credit()`), preventing invalid domain state transitions (e.g. negative balances or invalid debit amounts) regardless of caller invocation path. #### Consequences -- **Positive**: What benefits do we gain? -- **Negative / Trade-offs**: What challenges, overhead, or limitations are introduced? -- **Risks & Mitigations**: How do we mitigate negative impacts? -``` +- **Positive**: High cohesion, self-validating entities, reusable business logic across multiple services. +- **Negative / Trade-offs**: Entities must remain decoupled from infrastructure concerns (repositories, external APIs). +- **Risks & Mitigations**: Keep entity methods focused strictly on state invariants; delegate orchestration to domain services. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85413af..9ad19c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -266,3 +266,24 @@ Integration tests verify the full lifecycle of a transaction across actual conta 3. **Asynchronous Outbox Publisher Testing**: - Confirms the outbox poller successfully dispatches records to Kafka. - **Assertion**: Write an outbox record, wait for the scheduled poller execution, read the event from the Testcontainers Kafka consumer, and verify the message matches the expected transaction schema. + +--- + +## 13. Entity Model & Rich Domain Architecture + +To establish robust domain boundaries and prevent corrupt data state, the entity layer adheres to Rich Domain Model principles and precise database constraints: + +### A. Financial Precision (`BigDecimal`) +All monetary columns (`balance`, `amount`) are represented using `BigDecimal` mapped to database column definition `@Column(precision = 19, scale = 4, nullable = false)`. Floating-point binary arithmetic primitives (`double`, `float`) are prohibited to eliminate representation error accumulation. + +### B. Rich Domain Invariants +Entities encapsulate their own business invariants and state transitions: +- **`User.debit(BigDecimal amount)`**: Enforces positive debit amounts and verifies balance adequacy (`balance >= amount`). Throws `IllegalStateException` or domain exceptions if invariants fail. +- **`User.credit(BigDecimal amount)`**: Enforces positive credit amounts and updates account balance atomically in-memory. + +### C. Auditability & Optimistic Locking +- **Audit Timestamps**: `@CreationTimestamp Instant createdAt` and `@UpdateTimestamp Instant updatedAt` automatically track record creation and updates. +- **Optimistic Locking**: `@Version Long version` enables Hibernate to prevent lost updates during concurrent non-locking state updates. + +### D. Relational Foreign Key Integrity +The `Transaction` entity maintains explicit JPA `@ManyToOne(fetch = FetchType.LAZY)` foreign key relationships to `User` for `sender` and `receiver`, while retaining denormalized `senderUpiId` and `receiverUpiId` fields for index-optimized queries. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 635746a..17261f1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,9 +29,9 @@ Dockerfile JDK alignment, GraalVM profile fix, README cleanup. --- -## Phase 2 — Domain Model & API Hardening ⬜ +## Phase 2 — Domain Model & API Hardening 🔄 -### 2A — Entity Model & Rich Domain ⬜ +### 2A — Entity Model & Rich Domain ✅ `BigDecimal` financials, `@Column` constraints, JPA FK relationships, audit timestamps, status/type enums, rich domain methods (`debit`/`credit`), constructor injection. ### 2B — DTO Layer & Validation ⬜ diff --git a/pom.xml b/pom.xml index 0b983ad..11a1e0f 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ org.projectlombok lombok - true + provided @@ -66,6 +66,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/com/payflow/controller/TransactionController.java b/src/main/java/com/payflow/controller/TransactionController.java index d61f763..8c68cd5 100644 --- a/src/main/java/com/payflow/controller/TransactionController.java +++ b/src/main/java/com/payflow/controller/TransactionController.java @@ -1,6 +1,5 @@ package com.payflow.controller; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -13,8 +12,11 @@ @RequestMapping("/transactions") public class TransactionController { - @Autowired - private TransactionService transactionService; + private final TransactionService transactionService; + + public TransactionController(TransactionService transactionService) { + this.transactionService = transactionService; + } @PostMapping public Transaction sendMoney(@RequestBody Transaction transaction) { diff --git a/src/main/java/com/payflow/controller/UserController.java b/src/main/java/com/payflow/controller/UserController.java index 5c14ec7..2cacabe 100644 --- a/src/main/java/com/payflow/controller/UserController.java +++ b/src/main/java/com/payflow/controller/UserController.java @@ -1,9 +1,9 @@ package com.payflow.controller; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,8 +18,11 @@ @RequestMapping("/users") public class UserController { - @Autowired - private UserService userService; + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } @PostMapping public User registerUser(@RequestBody User user) { @@ -42,7 +45,7 @@ public Optional getUserByUpiId(@PathVariable String upiId) { } @GetMapping("/balance/{amount}") - public List getUsersWithBalanceAbove(@PathVariable Double amount) { + public List getUsersWithBalanceAbove(@PathVariable BigDecimal amount) { return userService.getUsersWithBalanceAbove(amount); } } diff --git a/src/main/java/com/payflow/entity/Transaction.java b/src/main/java/com/payflow/entity/Transaction.java index 4d56966..7e4c0af 100644 --- a/src/main/java/com/payflow/entity/Transaction.java +++ b/src/main/java/com/payflow/entity/Transaction.java @@ -1,67 +1,81 @@ package com.payflow.entity; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "transactions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class Transaction { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long transactionId; - private String senderUpiId; - - private String receiverUpiId; - - private Double amount; + @Column(nullable = false, unique = true, updatable = false) + private UUID referenceId; - private String note; - - public Transaction() { - } - - public Long getTransactionId() { - return transactionId; - } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; - public void setTransactionId(Long transactionId) { - this.transactionId = transactionId; - } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; - public String getSenderUpiId() { - return senderUpiId; - } + @Column(nullable = false, length = 30) + private String senderUpiId; - public void setSenderUpiId(String senderUpiId) { - this.senderUpiId = senderUpiId; - } + @Column(nullable = false, length = 30) + private String receiverUpiId; - public String getReceiverUpiId() { - return receiverUpiId; - } + @Column(precision = 19, scale = 4, nullable = false) + private BigDecimal amount; - public void setReceiverUpiId(String receiverUpiId) { - this.receiverUpiId = receiverUpiId; - } + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TransactionStatus status; - public Double getAmount() { - return amount; - } + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TransactionType type; - public void setAmount(Double amount) { - this.amount = amount; - } + @Column(length = 255) + private String note; - public String getNote() { - return note; - } + @CreationTimestamp + @Column(nullable = false, updatable = false) + private Instant createdAt; - public void setNote(String note) { - this.note = note; + @PrePersist + public void ensureReferenceId() { + if (this.referenceId == null) { + this.referenceId = UUID.randomUUID(); + } } } diff --git a/src/main/java/com/payflow/entity/TransactionStatus.java b/src/main/java/com/payflow/entity/TransactionStatus.java new file mode 100644 index 0000000..4e0b94a --- /dev/null +++ b/src/main/java/com/payflow/entity/TransactionStatus.java @@ -0,0 +1,5 @@ +package com.payflow.entity; + +public enum TransactionStatus { + INITIATED, COMPLETED, FAILED, REFUNDED +} diff --git a/src/main/java/com/payflow/entity/TransactionType.java b/src/main/java/com/payflow/entity/TransactionType.java new file mode 100644 index 0000000..02e55f9 --- /dev/null +++ b/src/main/java/com/payflow/entity/TransactionType.java @@ -0,0 +1,5 @@ +package com.payflow.entity; + +public enum TransactionType { + TRANSFER, REFUND +} diff --git a/src/main/java/com/payflow/entity/User.java b/src/main/java/com/payflow/entity/User.java index 2cb0811..c1b9299 100644 --- a/src/main/java/com/payflow/entity/User.java +++ b/src/main/java/com/payflow/entity/User.java @@ -1,69 +1,77 @@ package com.payflow.entity; +import java.math.BigDecimal; +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; + @Column(nullable = false, length = 100) private String name; - @Column(unique = true) + @Column(nullable = false, unique = true, length = 30) private String upiId; - private Double balance; + @Column(precision = 19, scale = 4, nullable = false) + private BigDecimal balance; + @Column(nullable = false, length = 15) private String phoneNumber; - public User() { - } - - public Long getUserId() { - return userId; - } - - public void setUserId(Long userId) { - this.userId = userId; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getUpiId() { - return upiId; - } - - public void setUpiId(String upiId) { - this.upiId = upiId; - } - - public Double getBalance() { - return balance; - } - - public void setBalance(Double balance) { - this.balance = balance; - } - - public String getPhoneNumber() { - return phoneNumber; + @Version + private Long version; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(nullable = false) + private Instant updatedAt; + + public void debit(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Debit amount must be positive"); + } + if (this.balance == null || this.balance.compareTo(amount) < 0) { + throw new IllegalStateException("Insufficient balance for UPI ID: " + this.upiId); + } + this.balance = this.balance.subtract(amount); } - public void setPhoneNumber(String phoneNumber) { - this.phoneNumber = phoneNumber; + public void credit(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Credit amount must be positive"); + } + if (this.balance == null) { + this.balance = BigDecimal.ZERO; + } + this.balance = this.balance.add(amount); } } diff --git a/src/main/java/com/payflow/repository/UserRepository.java b/src/main/java/com/payflow/repository/UserRepository.java index dc1e100..5e23280 100644 --- a/src/main/java/com/payflow/repository/UserRepository.java +++ b/src/main/java/com/payflow/repository/UserRepository.java @@ -1,5 +1,6 @@ package com.payflow.repository; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; @@ -16,5 +17,5 @@ public interface UserRepository extends JpaRepository { // JPQL query to find users with balance greater than a specified amount @Query("SELECT u FROM User u WHERE u.balance > :amount") - List findUsersWithBalanceGreaterThan(@Param("amount") Double amount); + List findUsersWithBalanceGreaterThan(@Param("amount") BigDecimal amount); } diff --git a/src/main/java/com/payflow/service/TransactionService.java b/src/main/java/com/payflow/service/TransactionService.java index da86c90..1ea3359 100644 --- a/src/main/java/com/payflow/service/TransactionService.java +++ b/src/main/java/com/payflow/service/TransactionService.java @@ -1,6 +1,5 @@ package com.payflow.service; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.payflow.entity.Transaction; @@ -9,10 +8,11 @@ @Service public class TransactionService { - // Spring creates the TransactionRepository bean and injects it during - // application startup. - @Autowired - private TransactionRepository transactionRepository; + private final TransactionRepository transactionRepository; + + public TransactionService(TransactionRepository transactionRepository) { + this.transactionRepository = transactionRepository; + } public Transaction sendMoney(Transaction transaction) { return transactionRepository.save(transaction); diff --git a/src/main/java/com/payflow/service/UserService.java b/src/main/java/com/payflow/service/UserService.java index fc448b9..33fac49 100644 --- a/src/main/java/com/payflow/service/UserService.java +++ b/src/main/java/com/payflow/service/UserService.java @@ -1,9 +1,9 @@ package com.payflow.service; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.payflow.entity.User; @@ -12,10 +12,11 @@ @Service public class UserService { - // Spring creates the UserRepository bean at startup and injects it here - // automatically. - @Autowired - private UserRepository userRepository; + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } public User registerUser(User user) { return userRepository.save(user); @@ -33,7 +34,7 @@ public Optional findByUpiId(String upiId) { return userRepository.findByUpiId(upiId); } - public List getUsersWithBalanceAbove(Double amount) { + public List getUsersWithBalanceAbove(BigDecimal amount) { return userRepository.findUsersWithBalanceGreaterThan(amount); } } diff --git a/src/test/java/com/payflow/entity/TransactionTest.java b/src/test/java/com/payflow/entity/TransactionTest.java new file mode 100644 index 0000000..88b47ea --- /dev/null +++ b/src/test/java/com/payflow/entity/TransactionTest.java @@ -0,0 +1,20 @@ +package com.payflow.entity; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class TransactionTest { + + @Test + void shouldEnsureReferenceIdOnPrePersist() { + Transaction transaction = Transaction.builder().senderUpiId("alice@upi").receiverUpiId("bob@upi") + .amount(new BigDecimal("100.00")).status(TransactionStatus.INITIATED).type(TransactionType.TRANSFER) + .build(); + + transaction.ensureReferenceId(); + assertNotNull(transaction.getReferenceId()); + } +} diff --git a/src/test/java/com/payflow/entity/UserTest.java b/src/test/java/com/payflow/entity/UserTest.java new file mode 100644 index 0000000..bb18d80 --- /dev/null +++ b/src/test/java/com/payflow/entity/UserTest.java @@ -0,0 +1,57 @@ +package com.payflow.entity; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + private User user; + + @BeforeEach + void setUp() { + user = User.builder().name("John Doe").upiId("john@upi").phoneNumber("9876543210") + .balance(new BigDecimal("500.00")).build(); + } + + @Test + @DisplayName("Should debit amount successfully when balance is sufficient") + void shouldDebitSuccessfully_whenBalanceIsSufficient() { + user.debit(new BigDecimal("200.00")); + assertEquals(new BigDecimal("300.00"), user.getBalance()); + } + + @Test + @DisplayName("Should throw IllegalStateException when debiting more than current balance") + void shouldThrowException_whenDebitingMoreThanBalance() { + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> user.debit(new BigDecimal("600.00"))); + assertEquals("Insufficient balance for UPI ID: john@upi", exception.getMessage()); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when debiting zero or negative amount") + void shouldThrowException_whenDebitingInvalidAmount() { + assertThrows(IllegalArgumentException.class, () -> user.debit(BigDecimal.ZERO)); + assertThrows(IllegalArgumentException.class, () -> user.debit(new BigDecimal("-50.00"))); + } + + @Test + @DisplayName("Should credit amount successfully") + void shouldCreditSuccessfully() { + user.credit(new BigDecimal("150.00")); + assertEquals(new BigDecimal("650.00"), user.getBalance()); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when crediting zero or negative amount") + void shouldThrowException_whenCreditingInvalidAmount() { + assertThrows(IllegalArgumentException.class, () -> user.credit(BigDecimal.ZERO)); + assertThrows(IllegalArgumentException.class, () -> user.credit(new BigDecimal("-10.00"))); + } +}