From ecabd12299cc58c519b24a481fcd32bfe0b809ec Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Sun, 27 Sep 2026 16:03:43 +0330 Subject: [PATCH] Add loan and collateral lending domains --- .../CollateralApplicationService.java | 46 ++++ .../application/CollateralDataExportPort.java | 11 + .../CollateralLiquidationPort.java | 10 + .../application/CollateralRepository.java | 11 + .../application/SubjectTokenVault.java | 8 + .../collateral/domain/Collateral.java | 122 ++++++++++ .../collateral/domain/CollateralStatus.java | 10 + .../collateral/domain/PriceOracle.java | 13 ++ .../payguard/collateral/domain/Valuation.java | 14 ++ .../domain/CollateralDomainTest.java | 37 ++++ .../src/test/resources/application.yaml | 5 + .../application/LoanApplicationService.java | 47 ++++ .../loan/application/LoanDataExportPort.java | 10 + .../loan/application/LoanRepository.java | 11 + .../loan/application/SubjectTokenVault.java | 8 + .../application/WalletSettlementPort.java | 12 + .../amg/payguard/loan/domain/Installment.java | 39 ++++ .../payguard/loan/domain/InterestAccrual.java | 20 ++ .../dev/amg/payguard/loan/domain/Loan.java | 209 ++++++++++++++++++ .../amg/payguard/loan/domain/LoanStatus.java | 11 + .../amg/payguard/loan/domain/Repayment.java | 21 ++ .../payguard/loan/domain/LoanDomainTest.java | 49 ++++ .../src/test/resources/application.yaml | 5 + 23 files changed, 729 insertions(+) create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java create mode 100644 payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java create mode 100644 payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java create mode 100644 payguard-collateral-service/src/test/resources/application.yaml create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java create mode 100644 payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java create mode 100644 payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java create mode 100644 payguard-loan-service/src/test/resources/application.yaml diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java new file mode 100644 index 0000000..d3a9305 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java @@ -0,0 +1,46 @@ +package dev.amg.payguard.collateral.application; + +import dev.amg.payguard.collateral.domain.Collateral; +import dev.amg.payguard.collateral.domain.CollateralStatus; +import dev.amg.payguard.collateral.domain.PriceOracle; +import dev.amg.payguard.collateral.domain.Valuation; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public final class CollateralApplicationService { + private final CollateralRepository repository; + private final CollateralLiquidationPort liquidation; + + public CollateralApplicationService( + CollateralRepository repository, CollateralLiquidationPort liquidation) { + this.repository = repository; + this.liquidation = liquidation; + } + + public Collateral lock(Collateral collateral) { + collateral.lock(); + return repository.save(collateral); + } + + public Collateral refresh( + UUID id, + BigDecimal exposure, + PriceOracle oracle, + java.util.Currency loanCurrency, + Instant now) { + Collateral c = repository.find(id).orElseThrow(); + PriceOracle.Quote q = + oracle.quote(id, java.util.Currency.getInstance(c.assetCurrency()), loanCurrency, now); + Collateral.Risk risk = + c.updateValuation(new Valuation(q.assetValue(), q.fxRate(), q.observedAt()), exposure); + if (risk.status() == CollateralStatus.LIQUIDATING) + liquidation.liquidate( + id, + exposure, + c.assetCurrency(), + loanCurrency.getCurrencyCode(), + id.toString() + ":" + q.observedAt()); + return repository.save(c); + } +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java new file mode 100644 index 0000000..94e6bf9 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java @@ -0,0 +1,11 @@ +package dev.amg.payguard.collateral.application; + +import java.util.Map; +import java.util.UUID; + +/** Restricted port used by the identity/privacy service for Articles 15, 17 and 20 requests. */ +public interface CollateralDataExportPort { + Map export(UUID collateralId); + + void pseudonymize(UUID collateralId, String retainedToken); +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java new file mode 100644 index 0000000..30a2168 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.collateral.application; + +import java.math.BigDecimal; +import java.util.UUID; + +@FunctionalInterface +public interface CollateralLiquidationPort { + void liquidate( + UUID collateralId, BigDecimal amount, String assetCurrency, String loanCurrency, String key); +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java new file mode 100644 index 0000000..e170fa8 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java @@ -0,0 +1,11 @@ +package dev.amg.payguard.collateral.application; + +import dev.amg.payguard.collateral.domain.Collateral; +import java.util.Optional; +import java.util.UUID; + +public interface CollateralRepository { + Collateral save(Collateral collateral); + + Optional find(UUID id); +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java new file mode 100644 index 0000000..feece4f --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.collateral.application; + +/** Boundary for KMS-backed encryption of the service-local linkage token. */ +public interface SubjectTokenVault { + String encrypt(String subjectId); + + String decrypt(String encryptedToken); +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java new file mode 100644 index 0000000..4eb2937 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java @@ -0,0 +1,122 @@ +package dev.amg.payguard.collateral.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@SuppressWarnings({"PMD.AvoidFieldNameMatchingMethodName"}) +public final class Collateral { + private final UUID id; + private final String ownerId; + private final UUID loanId; + private final BigDecimal amount; + private final String assetCurrency; + private final BigDecimal warningLtv; + private final BigDecimal marginLtv; + private final BigDecimal liquidationLtv; + private CollateralStatus status; + private Valuation latest; + private final List history = new ArrayList<>(); + + public Collateral( + UUID id, + String ownerId, + UUID loanId, + BigDecimal amount, + String assetCurrency, + BigDecimal warningLtv, + BigDecimal marginLtv, + BigDecimal liquidationLtv) { + this.id = Objects.requireNonNull(id); + this.ownerId = Objects.requireNonNull(ownerId); + this.loanId = Objects.requireNonNull(loanId); + if (amount == null || amount.signum() <= 0) throw new IllegalArgumentException("amount"); + this.amount = amount; + this.assetCurrency = Objects.requireNonNull(assetCurrency); + this.warningLtv = threshold(warningLtv); + this.marginLtv = threshold(marginLtv); + this.liquidationLtv = threshold(liquidationLtv); + if (this.warningLtv.compareTo(this.marginLtv) >= 0 + || this.marginLtv.compareTo(this.liquidationLtv) >= 0) + throw new IllegalArgumentException("threshold order"); + status = CollateralStatus.AVAILABLE; + } + + public void lock() { + if (status != CollateralStatus.AVAILABLE) + throw new IllegalStateException("collateral unavailable"); + status = CollateralStatus.LOCKED; + } + + public Risk updateValuation(Valuation valuation, BigDecimal exposure) { + Objects.requireNonNull(valuation); + if (status == CollateralStatus.RELEASED || status == CollateralStatus.LIQUIDATED) + throw new IllegalStateException("collateral closed"); + latest = valuation; + history.add(valuation); + BigDecimal ltv = + exposure.signum() == 0 + ? BigDecimal.ZERO + : exposure.divide(valuation.amount(), 18, RoundingMode.HALF_EVEN); + if (ltv.compareTo(liquidationLtv) >= 0) status = CollateralStatus.LIQUIDATING; + else if (ltv.compareTo(marginLtv) >= 0) status = CollateralStatus.MARGIN_CALL; + else if (status == CollateralStatus.MARGIN_CALL && ltv.compareTo(warningLtv) < 0) + status = CollateralStatus.LOCKED; + return new Risk(ltv, status); + } + + public void markLiquidated() { + if (status != CollateralStatus.LIQUIDATING && status != CollateralStatus.MARGIN_CALL) + throw new IllegalStateException("not liquidatable"); + status = CollateralStatus.LIQUIDATED; + } + + public void release() { + if (status != CollateralStatus.LOCKED && status != CollateralStatus.MARGIN_CALL) + throw new IllegalStateException("not releasable"); + status = CollateralStatus.RELEASED; + } + + private static BigDecimal threshold(BigDecimal v) { + if (v == null || v.signum() < 0 || v.compareTo(BigDecimal.ONE) > 0) + throw new IllegalArgumentException("LTV threshold"); + return v; + } + + public record Risk(BigDecimal ltv, CollateralStatus status) {} + + public UUID id() { + return id; + } + + public String ownerId() { + return ownerId; + } + + public UUID loanId() { + return loanId; + } + + public BigDecimal amount() { + return amount; + } + + public String assetCurrency() { + return assetCurrency; + } + + public CollateralStatus status() { + return status; + } + + public Valuation latest() { + return latest; + } + + public List history() { + return List.copyOf(history); + } +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java new file mode 100644 index 0000000..ec50ca6 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.collateral.domain; + +public enum CollateralStatus { + AVAILABLE, + LOCKED, + MARGIN_CALL, + LIQUIDATING, + LIQUIDATED, + RELEASED +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java new file mode 100644 index 0000000..f769906 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.collateral.domain; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Currency; +import java.util.UUID; + +@FunctionalInterface +public interface PriceOracle { + Quote quote(UUID collateralId, Currency assetCurrency, Currency loanCurrency, Instant now); + + record Quote(BigDecimal assetValue, BigDecimal fxRate, Instant observedAt) {} +} diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java new file mode 100644 index 0000000..05cb9a9 --- /dev/null +++ b/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.collateral.domain; + +import java.math.BigDecimal; +import java.time.Instant; + +public record Valuation(BigDecimal amount, BigDecimal fxRate, Instant observedAt) { + public Valuation { + if (amount == null + || amount.signum() < 0 + || fxRate == null + || fxRate.signum() <= 0 + || observedAt == null) throw new IllegalArgumentException("invalid valuation"); + } +} diff --git a/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java b/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java new file mode 100644 index 0000000..c43e574 --- /dev/null +++ b/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java @@ -0,0 +1,37 @@ +package dev.amg.payguard.collateral.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CollateralDomainTest { + @Test + void crossesMarginAndLiquidationThresholds() { + Collateral c = + new Collateral( + UUID.randomUUID(), + "owner-token", + UUID.randomUUID(), + new BigDecimal("1000"), + "USD", + new BigDecimal(".60"), + new BigDecimal(".70"), + new BigDecimal(".80")); + c.lock(); + assertEquals( + CollateralStatus.MARGIN_CALL, + c.updateValuation( + new Valuation(new BigDecimal("1400"), new BigDecimal("1"), Instant.now()), + new BigDecimal("1000")) + .status()); + assertEquals( + CollateralStatus.LIQUIDATING, + c.updateValuation( + new Valuation(new BigDecimal("1200"), new BigDecimal("1"), Instant.now()), + new BigDecimal("1000")) + .status()); + } +} diff --git a/payguard-collateral-service/src/test/resources/application.yaml b/payguard-collateral-service/src/test/resources/application.yaml new file mode 100644 index 0000000..f9b727c --- /dev/null +++ b/payguard-collateral-service/src/test/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration + - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java new file mode 100644 index 0000000..cac34cd --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java @@ -0,0 +1,47 @@ +package dev.amg.payguard.loan.application; + +import dev.amg.payguard.loan.domain.Loan; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.LocalDate; +import java.util.UUID; + +public final class LoanApplicationService { + private final LoanRepository repository; + private final WalletSettlementPort wallet; + private final Clock clock; + + public LoanApplicationService( + LoanRepository repository, WalletSettlementPort wallet, Clock clock) { + this.repository = repository; + this.wallet = wallet; + this.clock = clock; + } + + public Loan originate( + String borrowerId, + UUID collateralId, + BigDecimal principal, + BigDecimal rate, + int term, + LocalDate date) { + Loan loan = + Loan.originate(UUID.randomUUID(), borrowerId, collateralId, principal, rate, term, date); + return repository.save(loan); + } + + public Loan activate(UUID id, String walletId, String currency, String key) { + Loan loan = repository.find(id).orElseThrow(); + loan.activate(); + wallet.disburse(id, walletId, loan.principal(), currency, key); + return repository.save(loan); + } + + public Loan repay(UUID id, String walletId, BigDecimal amount, String currency, String key) { + Loan loan = repository.find(id).orElseThrow(); + loan.accrueUntil(LocalDate.now(clock)); + wallet.debit(id, walletId, amount, currency, key); + loan.repay(amount, clock); + return repository.save(loan); + } +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java new file mode 100644 index 0000000..96cdc0e --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.loan.application; + +import java.util.Map; +import java.util.UUID; + +public interface LoanDataExportPort { + Map export(UUID loanId); + + void pseudonymize(UUID loanId, String retainedToken); +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java new file mode 100644 index 0000000..08acc83 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java @@ -0,0 +1,11 @@ +package dev.amg.payguard.loan.application; + +import dev.amg.payguard.loan.domain.Loan; +import java.util.Optional; +import java.util.UUID; + +public interface LoanRepository { + Loan save(Loan loan); + + Optional find(UUID id); +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java new file mode 100644 index 0000000..5515e33 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.loan.application; + +/** Boundary for KMS-backed encryption of the service-local linkage token. */ +public interface SubjectTokenVault { + String encrypt(String subjectId); + + String decrypt(String encryptedToken); +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java new file mode 100644 index 0000000..08afc1d --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.loan.application; + +import java.math.BigDecimal; +import java.util.UUID; + +public interface WalletSettlementPort { + void disburse( + UUID loanId, String walletId, BigDecimal amount, String currency, String idempotencyKey); + + void debit( + UUID loanId, String walletId, BigDecimal amount, String currency, String idempotencyKey); +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java new file mode 100644 index 0000000..f9d3149 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java @@ -0,0 +1,39 @@ +package dev.amg.payguard.loan.domain; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +public record Installment( + UUID id, + int number, + LocalDate dueDate, + BigDecimal principalDue, + BigDecimal interestDue, + BigDecimal paidAmount) { + public Installment { + Objects.requireNonNull(id); + Objects.requireNonNull(dueDate); + Objects.requireNonNull(principalDue); + Objects.requireNonNull(interestDue); + Objects.requireNonNull(paidAmount); + if (number < 1 + || principalDue.signum() < 0 + || interestDue.signum() < 0 + || paidAmount.signum() < 0) throw new IllegalArgumentException("invalid installment"); + } + + public BigDecimal totalDue() { + return principalDue.add(interestDue); + } + + public BigDecimal remaining() { + return totalDue().subtract(paidAmount).max(BigDecimal.ZERO); + } + + public Installment apply(BigDecimal amount) { + return new Installment( + id, number, dueDate, principalDue, interestDue, paidAmount.add(amount.min(remaining()))); + } +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java new file mode 100644 index 0000000..21b7b40 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.loan.domain; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; + +public final class InterestAccrual { + private InterestAccrual() {} + + public static BigDecimal actual365( + BigDecimal principal, BigDecimal annualRate, LocalDate from, LocalDate to) { + if (to.isBefore(from)) throw new IllegalArgumentException("to before from"); + long days = to.toEpochDay() - from.toEpochDay(); + return principal + .multiply(annualRate, MathContext.DECIMAL128) + .multiply(BigDecimal.valueOf(days), MathContext.DECIMAL128) + .divide(BigDecimal.valueOf(365), 16, RoundingMode.HALF_EVEN); + } +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java new file mode 100644 index 0000000..8a70222 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java @@ -0,0 +1,209 @@ +package dev.amg.payguard.loan.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@SuppressWarnings({ + "PMD.AvoidFieldNameMatchingMethodName", + "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidReassigningParameters" +}) +public final class Loan { + private final UUID id; + private final String borrowerId; + private final UUID collateralId; + private final BigDecimal principal; + private final BigDecimal annualRate; + private final int termMonths; + private LoanStatus status; + private BigDecimal outstandingPrincipal; + private BigDecimal accruedInterest = BigDecimal.ZERO; + private LocalDate lastAccrualDate; + private final List installments; + private final List repayments = new ArrayList<>(); + + public Loan( + UUID id, + String borrowerId, + UUID collateralId, + BigDecimal principal, + BigDecimal annualRate, + int termMonths, + LocalDate startDate, + List installments, + LoanStatus status) { + this.id = Objects.requireNonNull(id); + this.borrowerId = Objects.requireNonNull(borrowerId); + this.collateralId = Objects.requireNonNull(collateralId); + this.principal = positive(principal); + this.annualRate = nonNegative(annualRate); + if (termMonths < 1) throw new IllegalArgumentException("term"); + this.termMonths = termMonths; + this.lastAccrualDate = Objects.requireNonNull(startDate); + this.installments = new ArrayList<>(Objects.requireNonNull(installments)); + this.status = Objects.requireNonNull(status); + this.outstandingPrincipal = this.principal; + } + + public static Loan originate( + UUID id, + String borrowerId, + UUID collateralId, + BigDecimal principal, + BigDecimal annualRate, + int termMonths, + LocalDate start) { + return new Loan( + id, + borrowerId, + collateralId, + principal, + annualRate, + termMonths, + start, + schedule(principal, annualRate, termMonths, start), + LoanStatus.PENDING); + } + + private static List schedule(BigDecimal p, BigDecimal rate, int n, LocalDate start) { + BigDecimal monthly = rate.divide(BigDecimal.valueOf(12), 18, RoundingMode.HALF_EVEN); + BigDecimal emi = + monthly.signum() == 0 + ? p.divide(BigDecimal.valueOf(n), 18, RoundingMode.HALF_EVEN) + : p.multiply(monthly) + .multiply(monthly.add(BigDecimal.ONE).pow(n)) + .divide( + monthly.add(BigDecimal.ONE).pow(n).subtract(BigDecimal.ONE), + 18, + RoundingMode.HALF_EVEN); + BigDecimal balance = p; + List result = new ArrayList<>(); + for (int i = 1; i <= n; i++) { + BigDecimal interest = balance.multiply(monthly).setScale(18, RoundingMode.HALF_EVEN); + BigDecimal principalDue = i == n ? balance : emi.subtract(interest).max(BigDecimal.ZERO); + balance = balance.subtract(principalDue); + result.add( + new Installment( + UUID.randomUUID(), i, start.plusMonths(i), principalDue, interest, BigDecimal.ZERO)); + } + return result; + } + + public void activate() { + require(LoanStatus.PENDING); + status = LoanStatus.ACTIVE; + } + + public void accrueUntil(LocalDate date) { + requireOpen(); + if (date.isBefore(lastAccrualDate)) throw new IllegalArgumentException("date before accrual"); + accruedInterest = + accruedInterest.add( + InterestAccrual.actual365(outstandingPrincipal, annualRate, lastAccrualDate, date)); + lastAccrualDate = date; + } + + public Repayment repay(BigDecimal amount, Clock clock) { + requireOpen(); + amount = positive(amount); + BigDecimal interest = amount.min(accruedInterest); + BigDecimal principalPaid = amount.subtract(interest).min(outstandingPrincipal); + accruedInterest = accruedInterest.subtract(interest); + outstandingPrincipal = outstandingPrincipal.subtract(principalPaid); + repayments.add(new Repayment(UUID.randomUUID(), interest, principalPaid, clock.instant())); + if (outstandingPrincipal.signum() == 0 && accruedInterest.signum() == 0) + status = LoanStatus.REPAID; + return repayments.getLast(); + } + + public void markDelinquent() { + if (status == LoanStatus.ACTIVE) status = LoanStatus.DELINQUENT; + } + + public void markLiquidated(BigDecimal proceeds) { + requireOpen(); + if (proceeds.compareTo(outstandingPrincipal.add(accruedInterest)) >= 0) { + outstandingPrincipal = BigDecimal.ZERO; + accruedInterest = BigDecimal.ZERO; + status = LoanStatus.LIQUIDATED; + } else { + outstandingPrincipal = outstandingPrincipal.subtract(proceeds.max(BigDecimal.ZERO)); + status = LoanStatus.DEFAULTED; + } + } + + public void dispute() { + if (status == LoanStatus.PENDING) status = LoanStatus.DISPUTED; + } + + private void requireOpen() { + if (status != LoanStatus.ACTIVE && status != LoanStatus.DELINQUENT) + throw new IllegalStateException("loan is not open"); + } + + private void require(LoanStatus expected) { + if (status != expected) + throw new IllegalStateException("expected " + expected + " but was " + status); + } + + private static BigDecimal positive(BigDecimal v) { + if (v == null || v.signum() <= 0) throw new IllegalArgumentException("amount must be positive"); + return v; + } + + private static BigDecimal nonNegative(BigDecimal v) { + if (v == null || v.signum() < 0) + throw new IllegalArgumentException("rate must be non-negative"); + return v; + } + + public UUID id() { + return id; + } + + public String borrowerId() { + return borrowerId; + } + + public UUID collateralId() { + return collateralId; + } + + public BigDecimal principal() { + return principal; + } + + public BigDecimal annualRate() { + return annualRate; + } + + public int termMonths() { + return termMonths; + } + + public LoanStatus status() { + return status; + } + + public BigDecimal outstandingPrincipal() { + return outstandingPrincipal; + } + + public BigDecimal accruedInterest() { + return accruedInterest; + } + + public List installments() { + return List.copyOf(installments); + } + + public List repayments() { + return List.copyOf(repayments); + } +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java new file mode 100644 index 0000000..3c4f4e2 --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java @@ -0,0 +1,11 @@ +package dev.amg.payguard.loan.domain; + +public enum LoanStatus { + PENDING, + ACTIVE, + DELINQUENT, + REPAID, + LIQUIDATED, + DEFAULTED, + DISPUTED +} diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java new file mode 100644 index 0000000..3b7efec --- /dev/null +++ b/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java @@ -0,0 +1,21 @@ +package dev.amg.payguard.loan.domain; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record Repayment(UUID id, BigDecimal interest, BigDecimal principal, Instant recordedAt) { + public Repayment { + Objects.requireNonNull(id); + Objects.requireNonNull(interest); + Objects.requireNonNull(principal); + Objects.requireNonNull(recordedAt); + if (interest.signum() < 0 || principal.signum() < 0) + throw new IllegalArgumentException("repayment amounts must be non-negative"); + } + + public BigDecimal total() { + return interest.add(principal); + } +} diff --git a/payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java b/payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java new file mode 100644 index 0000000..7956052 --- /dev/null +++ b/payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java @@ -0,0 +1,49 @@ +package dev.amg.payguard.loan.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class LoanDomainTest { + @Test + void generatesScheduleAndAccruesActual365() { + Loan loan = + Loan.originate( + UUID.randomUUID(), + "subject-token", + UUID.randomUUID(), + new BigDecimal("1200"), + new BigDecimal("0.12"), + 12, + LocalDate.of(2026, 1, 1)); + assertEquals(12, loan.installments().size()); + loan.activate(); + loan.accrueUntil(LocalDate.of(2026, 2, 1)); + assertTrue(loan.accruedInterest().compareTo(BigDecimal.ZERO) > 0); + } + + @Test + void allocatesInterestBeforePrincipal() { + Loan loan = + Loan.originate( + UUID.randomUUID(), + "s", + UUID.randomUUID(), + new BigDecimal("1000"), + new BigDecimal("0.10"), + 12, + LocalDate.of(2026, 1, 1)); + loan.activate(); + loan.accrueUntil(LocalDate.of(2026, 2, 1)); + BigDecimal before = loan.outstandingPrincipal(); + Repayment r = + loan.repay( + new BigDecimal("20"), + Clock.fixed(Instant.parse("2026-02-01T00:00:00Z"), ZoneOffset.UTC)); + assertTrue(r.interest().signum() > 0); + assertTrue(loan.outstandingPrincipal().compareTo(before) < 0); + } +} diff --git a/payguard-loan-service/src/test/resources/application.yaml b/payguard-loan-service/src/test/resources/application.yaml new file mode 100644 index 0000000..f9b727c --- /dev/null +++ b/payguard-loan-service/src/test/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + autoconfigure: + exclude: + - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration + - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration