Skip to content

Commit 5d2baf8

Browse files
feat(s3): application controllers + CollectionCommandHandler (STA-127) (#135)
Add REST controllers and domain command handler for the S3 Fiat On-Ramp service collection order lifecycle. Fix CollectionCommandHandlerTest stubbing to ignore random collectionId/pspTxnId fields. - CollectionRequest DTO with Jakarta validation (fiat-on-ramp-api) - CollectionCommandHandler: initiate (idempotent), get by ID, get by paymentId - CollectionResult wrapper for 201 vs 200 status distinction - CollectionController: POST/GET endpoints for collections + refund - GlobalExceptionHandler: maps domain exceptions to HTTP responses - FiatOnRampClient: Feign client with initiateCollection method - 6 unit tests (interaction-verification, BDD style) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c4d96a commit 5d2baf8

7 files changed

Lines changed: 624 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.stablecoin.payments.onramp.api;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Positive;
6+
7+
import java.math.BigDecimal;
8+
import java.util.UUID;
9+
10+
public record CollectionRequest(
11+
@NotNull UUID paymentId,
12+
@NotNull UUID correlationId,
13+
@NotNull @Positive BigDecimal amount,
14+
@NotBlank String currency,
15+
@NotBlank String paymentRailType,
16+
@NotBlank String railCountry,
17+
@NotBlank String railCurrency,
18+
@NotBlank String pspId,
19+
@NotBlank String pspName,
20+
@NotBlank String senderAccountHash,
21+
@NotBlank String senderBankCode,
22+
@NotBlank String senderAccountType,
23+
@NotBlank String senderAccountCountry
24+
) {}

fiat-on-ramp/fiat-on-ramp-client/src/main/java/com/stablecoin/payments/onramp/client/FiatOnRampClient.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.stablecoin.payments.onramp.client;
22

3+
import com.stablecoin.payments.onramp.api.CollectionRequest;
34
import com.stablecoin.payments.onramp.api.CollectionResponse;
45
import com.stablecoin.payments.onramp.api.RefundRequest;
56
import com.stablecoin.payments.onramp.api.RefundResponse;
@@ -15,6 +16,9 @@
1516
@FeignClient(name = "fiat-on-ramp-service", url = "${app.services.fiat-on-ramp.url}")
1617
public interface FiatOnRampClient {
1718

19+
@PostMapping(value = "/v1/collections", consumes = "application/json", produces = "application/json")
20+
CollectionResponse initiateCollection(@RequestBody CollectionRequest request);
21+
1822
@GetMapping(value = "/v1/collections/{collectionId}", produces = "application/json")
1923
CollectionResponse getCollection(@PathVariable("collectionId") UUID collectionId);
2024

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.stablecoin.payments.onramp.application.controller;
2+
3+
import com.stablecoin.payments.onramp.api.CollectionRequest;
4+
import com.stablecoin.payments.onramp.api.CollectionResponse;
5+
import com.stablecoin.payments.onramp.api.RefundRequest;
6+
import com.stablecoin.payments.onramp.api.RefundResponse;
7+
import com.stablecoin.payments.onramp.domain.model.AccountType;
8+
import com.stablecoin.payments.onramp.domain.model.BankAccount;
9+
import com.stablecoin.payments.onramp.domain.model.CollectionOrder;
10+
import com.stablecoin.payments.onramp.domain.model.Money;
11+
import com.stablecoin.payments.onramp.domain.model.PaymentRail;
12+
import com.stablecoin.payments.onramp.domain.model.PaymentRailType;
13+
import com.stablecoin.payments.onramp.domain.model.PspIdentifier;
14+
import com.stablecoin.payments.onramp.domain.model.Refund;
15+
import com.stablecoin.payments.onramp.domain.service.CollectionCommandHandler;
16+
import com.stablecoin.payments.onramp.domain.service.RefundCommandHandler;
17+
import jakarta.validation.Valid;
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
import org.springframework.http.HttpStatus;
21+
import org.springframework.http.ResponseEntity;
22+
import org.springframework.web.bind.annotation.GetMapping;
23+
import org.springframework.web.bind.annotation.PathVariable;
24+
import org.springframework.web.bind.annotation.PostMapping;
25+
import org.springframework.web.bind.annotation.RequestBody;
26+
import org.springframework.web.bind.annotation.RequestMapping;
27+
import org.springframework.web.bind.annotation.RequestParam;
28+
import org.springframework.web.bind.annotation.RestController;
29+
30+
import java.util.UUID;
31+
32+
/**
33+
* REST controller for collection order lifecycle management.
34+
* <p>
35+
* Thin HTTP handler that delegates all business logic to
36+
* {@link CollectionCommandHandler} and {@link RefundCommandHandler}.
37+
*/
38+
@Slf4j
39+
@RestController
40+
@RequestMapping("/v1/collections")
41+
@RequiredArgsConstructor
42+
public class CollectionController {
43+
44+
private final CollectionCommandHandler collectionCommandHandler;
45+
private final RefundCommandHandler refundCommandHandler;
46+
47+
/**
48+
* Initiates a new collection order.
49+
* <p>
50+
* Idempotent: returns 200 OK with the existing order if the same paymentId
51+
* is submitted again. Returns 201 CREATED on first creation.
52+
*/
53+
@PostMapping
54+
public ResponseEntity<CollectionResponse> initiateCollection(
55+
@Valid @RequestBody CollectionRequest request) {
56+
log.info("POST /v1/collections paymentId={}", request.paymentId());
57+
58+
var amount = new Money(request.amount(), request.currency());
59+
var paymentRail = new PaymentRail(
60+
PaymentRailType.valueOf(request.paymentRailType()),
61+
request.railCountry(),
62+
request.railCurrency());
63+
var psp = new PspIdentifier(request.pspId(), request.pspName());
64+
var senderAccount = new BankAccount(
65+
request.senderAccountHash(),
66+
request.senderBankCode(),
67+
AccountType.valueOf(request.senderAccountType()),
68+
request.senderAccountCountry());
69+
70+
var result = collectionCommandHandler.initiateCollection(
71+
request.paymentId(),
72+
request.correlationId(),
73+
amount,
74+
paymentRail,
75+
psp,
76+
senderAccount);
77+
78+
var response = toCollectionResponse(result.order());
79+
var status = result.created() ? HttpStatus.CREATED : HttpStatus.OK;
80+
81+
return ResponseEntity.status(status).body(response);
82+
}
83+
84+
/**
85+
* Retrieves a collection order by its ID.
86+
*/
87+
@GetMapping("/{collectionId}")
88+
public CollectionResponse getCollection(@PathVariable UUID collectionId) {
89+
log.info("GET /v1/collections/{}", collectionId);
90+
return toCollectionResponse(collectionCommandHandler.getCollection(collectionId));
91+
}
92+
93+
/**
94+
* Retrieves a collection order by its associated payment ID.
95+
*/
96+
@GetMapping
97+
public CollectionResponse getCollectionByPaymentId(@RequestParam UUID paymentId) {
98+
log.info("GET /v1/collections?paymentId={}", paymentId);
99+
return toCollectionResponse(collectionCommandHandler.getCollectionByPaymentId(paymentId));
100+
}
101+
102+
/**
103+
* Initiates a refund for a collected order.
104+
*/
105+
@PostMapping("/{collectionId}/refunds")
106+
public ResponseEntity<RefundResponse> initiateRefund(
107+
@PathVariable UUID collectionId,
108+
@Valid @RequestBody RefundRequest request) {
109+
log.info("POST /v1/collections/{}/refunds", collectionId);
110+
111+
var refundAmount = new Money(request.refundAmount(), request.currency());
112+
var refund = refundCommandHandler.initiateRefund(collectionId, refundAmount, request.reason());
113+
114+
return ResponseEntity.status(HttpStatus.CREATED).body(toRefundResponse(refund));
115+
}
116+
117+
private CollectionResponse toCollectionResponse(CollectionOrder order) {
118+
return new CollectionResponse(
119+
order.collectionId(),
120+
order.paymentId(),
121+
order.status().name(),
122+
order.paymentRail().rail().name(),
123+
order.psp().pspName(),
124+
order.pspReference(),
125+
order.pspSettledAt(),
126+
order.createdAt(),
127+
order.expiresAt());
128+
}
129+
130+
private RefundResponse toRefundResponse(Refund refund) {
131+
return new RefundResponse(
132+
refund.refundId(),
133+
refund.collectionId(),
134+
refund.status().name(),
135+
refund.refundAmount().amount(),
136+
refund.refundAmount().currency(),
137+
refund.initiatedAt(),
138+
refund.completedAt());
139+
}
140+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.stablecoin.payments.onramp.application.controller;
2+
3+
import com.stablecoin.payments.onramp.api.ApiError;
4+
import com.stablecoin.payments.onramp.domain.exception.CollectionOrderNotFoundException;
5+
import com.stablecoin.payments.onramp.domain.exception.RefundAmountExceededException;
6+
import com.stablecoin.payments.onramp.domain.exception.RefundNotAllowedException;
7+
import com.stablecoin.payments.onramp.domain.exception.RefundNotFoundException;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.validation.FieldError;
10+
import org.springframework.validation.ObjectError;
11+
import org.springframework.web.bind.MethodArgumentNotValidException;
12+
import org.springframework.web.bind.annotation.ExceptionHandler;
13+
import org.springframework.web.bind.annotation.ResponseStatus;
14+
import org.springframework.web.bind.annotation.RestControllerAdvice;
15+
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
16+
17+
import java.util.stream.Collectors;
18+
19+
import static org.springframework.http.HttpStatus.BAD_REQUEST;
20+
import static org.springframework.http.HttpStatus.CONFLICT;
21+
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
22+
import static org.springframework.http.HttpStatus.NOT_FOUND;
23+
import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY;
24+
25+
/**
26+
* Global exception handler for the Fiat On-Ramp service.
27+
* <p>
28+
* Maps domain exceptions to appropriate HTTP responses with error codes.
29+
*/
30+
@Slf4j
31+
@RestControllerAdvice
32+
public class GlobalExceptionHandler {
33+
34+
@ResponseStatus(BAD_REQUEST)
35+
@ExceptionHandler(MethodArgumentNotValidException.class)
36+
public ApiError handleValidation(MethodArgumentNotValidException ex) {
37+
var errors = ex.getBindingResult().getFieldErrors().stream()
38+
.collect(Collectors.groupingBy(
39+
FieldError::getField,
40+
Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList())));
41+
log.info("Validation failed: {}", errors);
42+
return ApiError.withErrors("OR-0001", BAD_REQUEST.getReasonPhrase(),
43+
"Invalid request content", errors);
44+
}
45+
46+
@ResponseStatus(BAD_REQUEST)
47+
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
48+
public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
49+
log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage());
50+
return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(),
51+
"Invalid value for parameter '" + ex.getName() + "'");
52+
}
53+
54+
@ResponseStatus(NOT_FOUND)
55+
@ExceptionHandler(CollectionOrderNotFoundException.class)
56+
public ApiError handleCollectionNotFound(CollectionOrderNotFoundException ex) {
57+
log.info("Collection order not found: {}", ex.getMessage());
58+
return ApiError.of(CollectionOrderNotFoundException.ERROR_CODE, NOT_FOUND.getReasonPhrase(),
59+
ex.getMessage());
60+
}
61+
62+
@ResponseStatus(NOT_FOUND)
63+
@ExceptionHandler(RefundNotFoundException.class)
64+
public ApiError handleRefundNotFound(RefundNotFoundException ex) {
65+
log.info("Refund not found: {}", ex.getMessage());
66+
return ApiError.of(RefundNotFoundException.ERROR_CODE, NOT_FOUND.getReasonPhrase(),
67+
ex.getMessage());
68+
}
69+
70+
@ResponseStatus(CONFLICT)
71+
@ExceptionHandler(RefundNotAllowedException.class)
72+
public ApiError handleRefundNotAllowed(RefundNotAllowedException ex) {
73+
log.info("Refund not allowed: {}", ex.getMessage());
74+
return ApiError.of(RefundNotAllowedException.ERROR_CODE, CONFLICT.getReasonPhrase(),
75+
ex.getMessage());
76+
}
77+
78+
@ResponseStatus(UNPROCESSABLE_ENTITY)
79+
@ExceptionHandler(RefundAmountExceededException.class)
80+
public ApiError handleRefundAmountExceeded(RefundAmountExceededException ex) {
81+
log.info("Refund amount exceeded: {}", ex.getMessage());
82+
return ApiError.of(RefundAmountExceededException.ERROR_CODE, UNPROCESSABLE_ENTITY.getReasonPhrase(),
83+
ex.getMessage());
84+
}
85+
86+
@ResponseStatus(CONFLICT)
87+
@ExceptionHandler(IllegalStateException.class)
88+
public ApiError handleIllegalState(IllegalStateException ex) {
89+
log.info("Invalid state transition: {}", ex.getMessage());
90+
return ApiError.of("OR-0002", CONFLICT.getReasonPhrase(), ex.getMessage());
91+
}
92+
93+
@ResponseStatus(BAD_REQUEST)
94+
@ExceptionHandler(IllegalArgumentException.class)
95+
public ApiError handleIllegalArgument(IllegalArgumentException ex) {
96+
log.info("Illegal argument: {}", ex.getMessage());
97+
return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage());
98+
}
99+
100+
@ResponseStatus(INTERNAL_SERVER_ERROR)
101+
@ExceptionHandler(Exception.class)
102+
public ApiError handleUnexpected(Exception ex) {
103+
log.error("Unexpected error: ", ex);
104+
return ApiError.of("OR-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(),
105+
INTERNAL_SERVER_ERROR.getReasonPhrase());
106+
}
107+
}

0 commit comments

Comments
 (0)