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")));
+ }
+}