Skip to content

Commit 8b7b204

Browse files
feat(s1): application controllers -- REST endpoints + CommandHandler (STA-114)
Add PaymentController (POST/GET/cancel), PaymentCommandHandler with idempotent replay via Idempotency-Key, Temporal workflow start/cancel signalling, and 14 new tests (192 total unit, 20 IT). Closes LIN-114 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 35db9a8 commit 8b7b204

13 files changed

Lines changed: 1080 additions & 0 deletions

File tree

payment-orchestrator/payment-orchestrator/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ dependencies {
127127

128128
// Test
129129
testImplementation("org.springframework.boot:spring-boot-starter-test")
130+
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
131+
testImplementation("org.springframework.boot:spring-boot-starter-security-test")
130132
testImplementation("org.springframework.kafka:spring-kafka-test")
131133
testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion")
132134
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.stablecoin.payments.orchestrator.application.controller;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
/**
6+
* Request DTO for cancelling a payment.
7+
*/
8+
public record CancelPaymentRequest(
9+
@NotBlank(message = "reason is required")
10+
String reason
11+
) {}

payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.stablecoin.payments.orchestrator.application.controller;
22

33
import com.stablecoin.payments.orchestrator.api.ApiError;
4+
import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException;
5+
import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException;
46
import jakarta.validation.ConstraintViolationException;
57
import lombok.extern.slf4j.Slf4j;
68
import org.springframework.http.HttpStatus;
@@ -14,9 +16,13 @@
1416
import java.util.stream.Collectors;
1517

1618
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.INTERNAL_ERROR;
19+
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_CANCELLABLE;
20+
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_FOUND;
1721
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.VALIDATION_ERROR;
1822
import static org.springframework.http.HttpStatus.BAD_REQUEST;
23+
import static org.springframework.http.HttpStatus.CONFLICT;
1924
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
25+
import static org.springframework.http.HttpStatus.NOT_FOUND;
2026
import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY;
2127

2228
@Slf4j
@@ -54,6 +60,20 @@ public ApiError handleIllegalArgument(IllegalArgumentException ex) {
5460
return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), ex.getMessage());
5561
}
5662

63+
@ResponseStatus(NOT_FOUND)
64+
@ExceptionHandler(PaymentNotFoundException.class)
65+
public ApiError handlePaymentNotFound(PaymentNotFoundException ex) {
66+
log.info("Payment not found: {}", ex.getMessage());
67+
return ApiError.of(PAYMENT_NOT_FOUND, NOT_FOUND.getReasonPhrase(), ex.getMessage());
68+
}
69+
70+
@ResponseStatus(CONFLICT)
71+
@ExceptionHandler(PaymentNotCancellableException.class)
72+
public ApiError handlePaymentNotCancellable(PaymentNotCancellableException ex) {
73+
log.info("Payment not cancellable: {}", ex.getMessage());
74+
return ApiError.of(PAYMENT_NOT_CANCELLABLE, CONFLICT.getReasonPhrase(), ex.getMessage());
75+
}
76+
5777
@ResponseStatus(UNPROCESSABLE_ENTITY)
5878
@ExceptionHandler(IllegalStateException.class)
5979
public ApiError handleInvalidState(IllegalStateException ex) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.stablecoin.payments.orchestrator.application.controller;
2+
3+
import jakarta.validation.constraints.DecimalMin;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
import java.math.BigDecimal;
8+
import java.util.UUID;
9+
10+
/**
11+
* Request DTO for initiating a cross-border payment.
12+
*/
13+
public record InitiatePaymentRequest(
14+
@NotNull(message = "senderId is required")
15+
UUID senderId,
16+
17+
@NotNull(message = "recipientId is required")
18+
UUID recipientId,
19+
20+
@NotNull(message = "sourceAmount is required")
21+
@DecimalMin(value = "0.01", message = "sourceAmount must be positive")
22+
BigDecimal sourceAmount,
23+
24+
@NotBlank(message = "sourceCurrency is required")
25+
String sourceCurrency,
26+
27+
@NotBlank(message = "targetCurrency is required")
28+
String targetCurrency,
29+
30+
@NotBlank(message = "sourceCountry is required")
31+
String sourceCountry,
32+
33+
@NotBlank(message = "targetCountry is required")
34+
String targetCountry
35+
) {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.stablecoin.payments.orchestrator.application.controller;
2+
3+
import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler;
4+
import jakarta.validation.Valid;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PathVariable;
11+
import org.springframework.web.bind.annotation.PostMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
13+
import org.springframework.web.bind.annotation.RequestHeader;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
import java.util.UUID;
18+
19+
/**
20+
* REST controller for payment lifecycle management.
21+
* <p>
22+
* Thin HTTP handler that delegates all business logic to {@link PaymentCommandHandler}.
23+
*/
24+
@Slf4j
25+
@RestController
26+
@RequestMapping("/v1/payments")
27+
@RequiredArgsConstructor
28+
public class PaymentController {
29+
30+
private final PaymentCommandHandler commandHandler;
31+
32+
/**
33+
* Initiates a new cross-border payment.
34+
* <p>
35+
* Idempotent: returns 200 with existing payment if the same Idempotency-Key is reused.
36+
* Returns 201 on first creation.
37+
*/
38+
@PostMapping
39+
public ResponseEntity<PaymentResponse> initiatePayment(
40+
@RequestHeader("Idempotency-Key") String idempotencyKey,
41+
@Valid @RequestBody InitiatePaymentRequest request) {
42+
log.info("POST /v1/payments idempotencyKey={}, senderId={}", idempotencyKey, request.senderId());
43+
44+
var result = commandHandler.initiatePayment(
45+
idempotencyKey,
46+
UUID.randomUUID(),
47+
request.senderId(),
48+
request.recipientId(),
49+
request.sourceAmount(),
50+
request.sourceCurrency(),
51+
request.targetCurrency(),
52+
request.sourceCountry(),
53+
request.targetCountry()
54+
);
55+
56+
var response = PaymentResponse.from(result.payment());
57+
var status = result.replay() ? HttpStatus.OK : HttpStatus.CREATED;
58+
59+
return ResponseEntity.status(status).body(response);
60+
}
61+
62+
/**
63+
* Retrieves a payment by its ID.
64+
*/
65+
@GetMapping("/{paymentId}")
66+
public PaymentResponse getPayment(@PathVariable UUID paymentId) {
67+
log.info("GET /v1/payments/{}", paymentId);
68+
return PaymentResponse.from(commandHandler.getPayment(paymentId));
69+
}
70+
71+
/**
72+
* Cancels a payment by sending a cancel signal to the workflow.
73+
* Returns 200 if accepted, 409 if the payment is in a terminal state.
74+
*/
75+
@PostMapping("/{paymentId}/cancel")
76+
public PaymentResponse cancelPayment(
77+
@PathVariable UUID paymentId,
78+
@Valid @RequestBody CancelPaymentRequest request) {
79+
log.info("POST /v1/payments/{}/cancel reason={}", paymentId, request.reason());
80+
return PaymentResponse.from(commandHandler.cancelPayment(paymentId, request.reason()));
81+
}
82+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.stablecoin.payments.orchestrator.application.controller;
2+
3+
import com.stablecoin.payments.orchestrator.domain.model.Payment;
4+
5+
import java.math.BigDecimal;
6+
import java.time.Instant;
7+
import java.util.UUID;
8+
9+
/**
10+
* Response DTO representing a payment in the API layer.
11+
*/
12+
public record PaymentResponse(
13+
UUID paymentId,
14+
String idempotencyKey,
15+
UUID correlationId,
16+
String state,
17+
UUID senderId,
18+
UUID recipientId,
19+
BigDecimal sourceAmount,
20+
String sourceCurrency,
21+
String targetCurrency,
22+
BigDecimal targetAmount,
23+
String sourceCountry,
24+
String targetCountry,
25+
String failureReason,
26+
Instant createdAt,
27+
Instant updatedAt,
28+
Instant expiresAt
29+
) {
30+
31+
/**
32+
* Maps a domain {@link Payment} aggregate to the API response DTO.
33+
*/
34+
public static PaymentResponse from(Payment payment) {
35+
return new PaymentResponse(
36+
payment.paymentId(),
37+
payment.idempotencyKey(),
38+
payment.correlationId(),
39+
payment.state().name(),
40+
payment.senderId(),
41+
payment.recipientId(),
42+
payment.sourceAmount().amount(),
43+
payment.sourceCurrency(),
44+
payment.targetCurrency(),
45+
payment.targetAmount() != null ? payment.targetAmount().amount() : null,
46+
payment.corridor().sourceCountry(),
47+
payment.corridor().targetCountry(),
48+
payment.failureReason(),
49+
payment.createdAt(),
50+
payment.updatedAt(),
51+
payment.expiresAt()
52+
);
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.stablecoin.payments.orchestrator.domain.model;
2+
3+
import java.util.UUID;
4+
5+
/**
6+
* Thrown when attempting to cancel a payment that is in a terminal state
7+
* and cannot be cancelled.
8+
*/
9+
public class PaymentNotCancellableException extends RuntimeException {
10+
11+
public PaymentNotCancellableException(UUID paymentId, PaymentState state) {
12+
super("Payment %s cannot be cancelled in state %s".formatted(paymentId, state));
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.stablecoin.payments.orchestrator.domain.model;
2+
3+
import java.util.UUID;
4+
5+
/**
6+
* Thrown when a payment cannot be found by its ID.
7+
*/
8+
public class PaymentNotFoundException extends RuntimeException {
9+
10+
public PaymentNotFoundException(UUID paymentId) {
11+
super("Payment not found: " + paymentId);
12+
}
13+
}

0 commit comments

Comments
 (0)