Skip to content

Commit ff248bb

Browse files
Add loan and collateral lending domains (#15)
1 parent e312ca5 commit ff248bb

23 files changed

Lines changed: 729 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.amg.payguard.collateral.application;
2+
3+
import dev.amg.payguard.collateral.domain.Collateral;
4+
import dev.amg.payguard.collateral.domain.CollateralStatus;
5+
import dev.amg.payguard.collateral.domain.PriceOracle;
6+
import dev.amg.payguard.collateral.domain.Valuation;
7+
import java.math.BigDecimal;
8+
import java.time.Instant;
9+
import java.util.UUID;
10+
11+
public final class CollateralApplicationService {
12+
private final CollateralRepository repository;
13+
private final CollateralLiquidationPort liquidation;
14+
15+
public CollateralApplicationService(
16+
CollateralRepository repository, CollateralLiquidationPort liquidation) {
17+
this.repository = repository;
18+
this.liquidation = liquidation;
19+
}
20+
21+
public Collateral lock(Collateral collateral) {
22+
collateral.lock();
23+
return repository.save(collateral);
24+
}
25+
26+
public Collateral refresh(
27+
UUID id,
28+
BigDecimal exposure,
29+
PriceOracle oracle,
30+
java.util.Currency loanCurrency,
31+
Instant now) {
32+
Collateral c = repository.find(id).orElseThrow();
33+
PriceOracle.Quote q =
34+
oracle.quote(id, java.util.Currency.getInstance(c.assetCurrency()), loanCurrency, now);
35+
Collateral.Risk risk =
36+
c.updateValuation(new Valuation(q.assetValue(), q.fxRate(), q.observedAt()), exposure);
37+
if (risk.status() == CollateralStatus.LIQUIDATING)
38+
liquidation.liquidate(
39+
id,
40+
exposure,
41+
c.assetCurrency(),
42+
loanCurrency.getCurrencyCode(),
43+
id.toString() + ":" + q.observedAt());
44+
return repository.save(c);
45+
}
46+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package dev.amg.payguard.collateral.application;
2+
3+
import java.util.Map;
4+
import java.util.UUID;
5+
6+
/** Restricted port used by the identity/privacy service for Articles 15, 17 and 20 requests. */
7+
public interface CollateralDataExportPort {
8+
Map<String, Object> export(UUID collateralId);
9+
10+
void pseudonymize(UUID collateralId, String retainedToken);
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package dev.amg.payguard.collateral.application;
2+
3+
import java.math.BigDecimal;
4+
import java.util.UUID;
5+
6+
@FunctionalInterface
7+
public interface CollateralLiquidationPort {
8+
void liquidate(
9+
UUID collateralId, BigDecimal amount, String assetCurrency, String loanCurrency, String key);
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package dev.amg.payguard.collateral.application;
2+
3+
import dev.amg.payguard.collateral.domain.Collateral;
4+
import java.util.Optional;
5+
import java.util.UUID;
6+
7+
public interface CollateralRepository {
8+
Collateral save(Collateral collateral);
9+
10+
Optional<Collateral> find(UUID id);
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package dev.amg.payguard.collateral.application;
2+
3+
/** Boundary for KMS-backed encryption of the service-local linkage token. */
4+
public interface SubjectTokenVault {
5+
String encrypt(String subjectId);
6+
7+
String decrypt(String encryptedToken);
8+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package dev.amg.payguard.collateral.domain;
2+
3+
import java.math.BigDecimal;
4+
import java.math.RoundingMode;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.Objects;
8+
import java.util.UUID;
9+
10+
@SuppressWarnings({"PMD.AvoidFieldNameMatchingMethodName"})
11+
public final class Collateral {
12+
private final UUID id;
13+
private final String ownerId;
14+
private final UUID loanId;
15+
private final BigDecimal amount;
16+
private final String assetCurrency;
17+
private final BigDecimal warningLtv;
18+
private final BigDecimal marginLtv;
19+
private final BigDecimal liquidationLtv;
20+
private CollateralStatus status;
21+
private Valuation latest;
22+
private final List<Valuation> history = new ArrayList<>();
23+
24+
public Collateral(
25+
UUID id,
26+
String ownerId,
27+
UUID loanId,
28+
BigDecimal amount,
29+
String assetCurrency,
30+
BigDecimal warningLtv,
31+
BigDecimal marginLtv,
32+
BigDecimal liquidationLtv) {
33+
this.id = Objects.requireNonNull(id);
34+
this.ownerId = Objects.requireNonNull(ownerId);
35+
this.loanId = Objects.requireNonNull(loanId);
36+
if (amount == null || amount.signum() <= 0) throw new IllegalArgumentException("amount");
37+
this.amount = amount;
38+
this.assetCurrency = Objects.requireNonNull(assetCurrency);
39+
this.warningLtv = threshold(warningLtv);
40+
this.marginLtv = threshold(marginLtv);
41+
this.liquidationLtv = threshold(liquidationLtv);
42+
if (this.warningLtv.compareTo(this.marginLtv) >= 0
43+
|| this.marginLtv.compareTo(this.liquidationLtv) >= 0)
44+
throw new IllegalArgumentException("threshold order");
45+
status = CollateralStatus.AVAILABLE;
46+
}
47+
48+
public void lock() {
49+
if (status != CollateralStatus.AVAILABLE)
50+
throw new IllegalStateException("collateral unavailable");
51+
status = CollateralStatus.LOCKED;
52+
}
53+
54+
public Risk updateValuation(Valuation valuation, BigDecimal exposure) {
55+
Objects.requireNonNull(valuation);
56+
if (status == CollateralStatus.RELEASED || status == CollateralStatus.LIQUIDATED)
57+
throw new IllegalStateException("collateral closed");
58+
latest = valuation;
59+
history.add(valuation);
60+
BigDecimal ltv =
61+
exposure.signum() == 0
62+
? BigDecimal.ZERO
63+
: exposure.divide(valuation.amount(), 18, RoundingMode.HALF_EVEN);
64+
if (ltv.compareTo(liquidationLtv) >= 0) status = CollateralStatus.LIQUIDATING;
65+
else if (ltv.compareTo(marginLtv) >= 0) status = CollateralStatus.MARGIN_CALL;
66+
else if (status == CollateralStatus.MARGIN_CALL && ltv.compareTo(warningLtv) < 0)
67+
status = CollateralStatus.LOCKED;
68+
return new Risk(ltv, status);
69+
}
70+
71+
public void markLiquidated() {
72+
if (status != CollateralStatus.LIQUIDATING && status != CollateralStatus.MARGIN_CALL)
73+
throw new IllegalStateException("not liquidatable");
74+
status = CollateralStatus.LIQUIDATED;
75+
}
76+
77+
public void release() {
78+
if (status != CollateralStatus.LOCKED && status != CollateralStatus.MARGIN_CALL)
79+
throw new IllegalStateException("not releasable");
80+
status = CollateralStatus.RELEASED;
81+
}
82+
83+
private static BigDecimal threshold(BigDecimal v) {
84+
if (v == null || v.signum() < 0 || v.compareTo(BigDecimal.ONE) > 0)
85+
throw new IllegalArgumentException("LTV threshold");
86+
return v;
87+
}
88+
89+
public record Risk(BigDecimal ltv, CollateralStatus status) {}
90+
91+
public UUID id() {
92+
return id;
93+
}
94+
95+
public String ownerId() {
96+
return ownerId;
97+
}
98+
99+
public UUID loanId() {
100+
return loanId;
101+
}
102+
103+
public BigDecimal amount() {
104+
return amount;
105+
}
106+
107+
public String assetCurrency() {
108+
return assetCurrency;
109+
}
110+
111+
public CollateralStatus status() {
112+
return status;
113+
}
114+
115+
public Valuation latest() {
116+
return latest;
117+
}
118+
119+
public List<Valuation> history() {
120+
return List.copyOf(history);
121+
}
122+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package dev.amg.payguard.collateral.domain;
2+
3+
public enum CollateralStatus {
4+
AVAILABLE,
5+
LOCKED,
6+
MARGIN_CALL,
7+
LIQUIDATING,
8+
LIQUIDATED,
9+
RELEASED
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.amg.payguard.collateral.domain;
2+
3+
import java.math.BigDecimal;
4+
import java.time.Instant;
5+
import java.util.Currency;
6+
import java.util.UUID;
7+
8+
@FunctionalInterface
9+
public interface PriceOracle {
10+
Quote quote(UUID collateralId, Currency assetCurrency, Currency loanCurrency, Instant now);
11+
12+
record Quote(BigDecimal assetValue, BigDecimal fxRate, Instant observedAt) {}
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package dev.amg.payguard.collateral.domain;
2+
3+
import java.math.BigDecimal;
4+
import java.time.Instant;
5+
6+
public record Valuation(BigDecimal amount, BigDecimal fxRate, Instant observedAt) {
7+
public Valuation {
8+
if (amount == null
9+
|| amount.signum() < 0
10+
|| fxRate == null
11+
|| fxRate.signum() <= 0
12+
|| observedAt == null) throw new IllegalArgumentException("invalid valuation");
13+
}
14+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package dev.amg.payguard.collateral.domain;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.math.BigDecimal;
6+
import java.time.Instant;
7+
import java.util.UUID;
8+
import org.junit.jupiter.api.Test;
9+
10+
class CollateralDomainTest {
11+
@Test
12+
void crossesMarginAndLiquidationThresholds() {
13+
Collateral c =
14+
new Collateral(
15+
UUID.randomUUID(),
16+
"owner-token",
17+
UUID.randomUUID(),
18+
new BigDecimal("1000"),
19+
"USD",
20+
new BigDecimal(".60"),
21+
new BigDecimal(".70"),
22+
new BigDecimal(".80"));
23+
c.lock();
24+
assertEquals(
25+
CollateralStatus.MARGIN_CALL,
26+
c.updateValuation(
27+
new Valuation(new BigDecimal("1400"), new BigDecimal("1"), Instant.now()),
28+
new BigDecimal("1000"))
29+
.status());
30+
assertEquals(
31+
CollateralStatus.LIQUIDATING,
32+
c.updateValuation(
33+
new Valuation(new BigDecimal("1200"), new BigDecimal("1"), Instant.now()),
34+
new BigDecimal("1000"))
35+
.status());
36+
}
37+
}

0 commit comments

Comments
 (0)