Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> export(UUID collateralId);

void pseudonymize(UUID collateralId, String retainedToken);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<Collateral> find(UUID id);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<Valuation> 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<Valuation> history() {
return List.copyOf(history);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.amg.payguard.collateral.domain;

public enum CollateralStatus {
AVAILABLE,
LOCKED,
MARGIN_CALL,
LIQUIDATING,
LIQUIDATED,
RELEASED
}
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
spring:
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
- org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.amg.payguard.loan.application;

import java.util.Map;
import java.util.UUID;

public interface LoanDataExportPort {
Map<String, Object> export(UUID loanId);

void pseudonymize(UUID loanId, String retainedToken);
}
Original file line number Diff line number Diff line change
@@ -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<Loan> find(UUID id);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading