Skip to content

Commit c36e87b

Browse files
feat(s5): Circle USDC redemption adapter + 7 WireMock tests (STA-151) (#155)
* feat(s5): infrastructure persistence — JPA entities, repos, adapters, 24 ITs (STA-149, STA-150) 3 JPA entities (PayoutOrder with @Version optimistic locking, StablecoinRedemption, OffRampTransaction with @JdbcTypeCode(SqlTypes.JSON) for JSONB), 3 Spring Data JPA repositories, 4 MapStruct mappers (including PayoutOrderEntityUpdater for in-place Hibernate update), 3 persistence adapters implementing domain ports, V2 migration adding BankAccount (4 cols) and MobileMoneyAccount (3 cols) to payout_orders. 24 integration tests: 13 PayoutOrder (CRUD, state machine happy/failure paths, unique constraints, findBy queries), 5 StablecoinRedemption (save, findById, findByPayoutId, ticker round-trip), 6 OffRampTransaction (append-only, JSONB round-trip, multiple per payout). 181 total tests (149 unit + 8 ArchUnit + 24 IT). Closes STA-149 Closes STA-150 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(s5): address CodeRabbit review — fail-fast mapper, scoped grants, ordered queries, new ITs (STA-150) - Fail-fast on partial bank/mobile columns in PayoutOrderPersistenceMapper - Scope sp_readonly GRANT to service-specific tables in V2 migration - Add deterministic ordering to OffRampTransactionJpaRepository (receivedAt ASC) - Add optimistic locking IT for PayoutOrderEntity @Version - Add FK constraint IT for StablecoinRedemption Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(s5): Circle USDC redemption adapter + 7 WireMock tests (STA-151) CircleRedemptionAdapter implements RedemptionGateway port via Circle Business Account Payouts API (POST /v1/businessAccount/payouts). Bearer API key auth, @CIRCUITBREAKER, HTTP/1.1. Package-private ACL DTOs. FallbackAdaptersConfig provides dev RedemptionGateway. 188 total tests (149 unit + 8 ArchUnit + 7 WireMock + 24 IT). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b259abf commit c36e87b

7 files changed

Lines changed: 422 additions & 0 deletions

File tree

fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/config/FallbackAdaptersConfig.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package com.stablecoin.payments.offramp.config;
22

3+
import com.stablecoin.payments.offramp.domain.port.RedemptionGateway;
4+
import com.stablecoin.payments.offramp.domain.port.RedemptionResult;
35
import lombok.extern.slf4j.Slf4j;
46
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
57
import org.springframework.context.annotation.Bean;
68
import org.springframework.context.annotation.Configuration;
79

10+
import java.math.BigDecimal;
811
import java.time.Clock;
12+
import java.time.Instant;
13+
import java.util.UUID;
914

1015
/**
1116
* Provides fallback (dev/test) implementations of outbound ports
@@ -17,6 +22,21 @@
1722
@Configuration
1823
public class FallbackAdaptersConfig {
1924

25+
@Bean
26+
@ConditionalOnMissingBean
27+
public RedemptionGateway fallbackRedemptionGateway() {
28+
return request -> {
29+
log.warn("[FALLBACK-REDEMPTION] Using dev redemption gateway payoutId={} amount={}",
30+
request.payoutId(), request.amount());
31+
return new RedemptionResult(
32+
"dev-redeem-" + UUID.randomUUID(),
33+
request.amount().multiply(new BigDecimal("0.92")),
34+
"EUR",
35+
Instant.now()
36+
);
37+
};
38+
}
39+
2040
@Bean
2141
@ConditionalOnMissingBean
2242
public Clock clock() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.stablecoin.payments.offramp.infrastructure.provider.circle;
2+
3+
/**
4+
* ACL DTO for Circle Business Account Payout API request.
5+
* Package-private — never leaks to domain.
6+
*/
7+
record CirclePayoutRequest(
8+
String idempotencyKey,
9+
CircleDestination destination,
10+
CircleAmount amount
11+
) {
12+
13+
record CircleDestination(String type, String id) {}
14+
15+
record CircleAmount(String amount, String currency) {}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.stablecoin.payments.offramp.infrastructure.provider.circle;
2+
3+
/**
4+
* ACL DTO for Circle Business Account Payout API response.
5+
* Package-private — never leaks to domain.
6+
*/
7+
record CirclePayoutResponse(CirclePayoutData data) {
8+
9+
record CirclePayoutData(
10+
String id,
11+
CirclePayoutAmount amount,
12+
String status,
13+
String createDate
14+
) {}
15+
16+
record CirclePayoutAmount(String amount, String currency) {}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.stablecoin.payments.offramp.infrastructure.provider.circle;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
@ConfigurationProperties(prefix = "app.redemption.circle")
6+
public record CircleProperties(String baseUrl, String apiKey, String destinationId, int timeoutSeconds) {
7+
8+
public CircleProperties {
9+
if (baseUrl == null || baseUrl.isBlank()) {
10+
baseUrl = "https://api-sandbox.circle.com";
11+
}
12+
if (apiKey == null || apiKey.isBlank()) {
13+
apiKey = "SAND_API_KEY_DEFAULT";
14+
}
15+
if (destinationId == null || destinationId.isBlank()) {
16+
destinationId = "default-wire-id";
17+
}
18+
if (timeoutSeconds <= 0) {
19+
timeoutSeconds = 10;
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.stablecoin.payments.offramp.infrastructure.provider.circle;
2+
3+
import com.stablecoin.payments.offramp.domain.port.RedemptionGateway;
4+
import com.stablecoin.payments.offramp.domain.port.RedemptionRequest;
5+
import com.stablecoin.payments.offramp.domain.port.RedemptionResult;
6+
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
9+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
10+
import org.springframework.http.HttpHeaders;
11+
import org.springframework.http.MediaType;
12+
import org.springframework.http.client.JdkClientHttpRequestFactory;
13+
import org.springframework.stereotype.Component;
14+
import org.springframework.web.client.RestClient;
15+
16+
import java.math.BigDecimal;
17+
import java.net.http.HttpClient;
18+
import java.net.http.HttpClient.Version;
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
22+
@Slf4j
23+
@Component
24+
@ConditionalOnProperty(name = "app.redemption.provider", havingValue = "circle")
25+
@EnableConfigurationProperties(CircleProperties.class)
26+
public class CircleRedemptionAdapter implements RedemptionGateway {
27+
28+
private final RestClient restClient;
29+
private final CircleProperties properties;
30+
31+
public CircleRedemptionAdapter(CircleProperties properties) {
32+
this.properties = properties;
33+
34+
var httpClient = HttpClient.newBuilder()
35+
.version(Version.HTTP_1_1)
36+
.connectTimeout(Duration.ofSeconds(properties.timeoutSeconds()))
37+
.build();
38+
39+
var requestFactory = new JdkClientHttpRequestFactory(httpClient);
40+
requestFactory.setReadTimeout(Duration.ofSeconds(properties.timeoutSeconds()));
41+
42+
this.restClient = RestClient.builder()
43+
.baseUrl(properties.baseUrl())
44+
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.apiKey())
45+
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
46+
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
47+
.requestFactory(requestFactory)
48+
.build();
49+
}
50+
51+
@Override
52+
@CircuitBreaker(name = "circle", fallbackMethod = "redeemFallback")
53+
public RedemptionResult redeem(RedemptionRequest request) {
54+
log.info("[CIRCLE] Redeeming stablecoin payoutId={} stablecoin={} amount={}",
55+
request.payoutId(), request.stablecoin(), request.amount());
56+
57+
var circleRequest = new CirclePayoutRequest(
58+
request.payoutId().toString(),
59+
new CirclePayoutRequest.CircleDestination("wire", properties.destinationId()),
60+
new CirclePayoutRequest.CircleAmount(request.amount().toPlainString(), "USD")
61+
);
62+
63+
var response = restClient.post()
64+
.uri("/v1/businessAccount/payouts")
65+
.body(circleRequest)
66+
.retrieve()
67+
.body(CirclePayoutResponse.class);
68+
69+
var payoutData = response.data();
70+
71+
log.info("[CIRCLE] Redemption initiated payoutId={} circleRef={} status={} fiatAmount={} currency={}",
72+
request.payoutId(), payoutData.id(), payoutData.status(),
73+
payoutData.amount().amount(), payoutData.amount().currency());
74+
75+
return new RedemptionResult(
76+
payoutData.id(),
77+
new BigDecimal(payoutData.amount().amount()),
78+
payoutData.amount().currency(),
79+
Instant.now()
80+
);
81+
}
82+
83+
@SuppressWarnings("unused")
84+
private RedemptionResult redeemFallback(RedemptionRequest request, Exception ex) {
85+
log.error("[CIRCLE] Circuit breaker open — redemption failed payoutId={}",
86+
request.payoutId(), ex);
87+
throw new IllegalStateException("Circle redemption unavailable", ex);
88+
}
89+
}

fiat-off-ramp/fiat-off-ramp/src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ springdoc:
117117
app:
118118
security:
119119
enabled: false
120+
redemption:
121+
circle:
122+
base-url: ${CIRCLE_BASE_URL:https://api-sandbox.circle.com}
123+
api-key: ${CIRCLE_API_KEY:SAND_API_KEY_DEFAULT}
124+
destination-id: ${CIRCLE_DESTINATION_ID:default-wire-id}
125+
timeout-seconds: 10
120126

121127
logging:
122128
level:

0 commit comments

Comments
 (0)