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
2 changes: 2 additions & 0 deletions payment-orchestrator/payment-orchestrator/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ dependencies {

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.kafka:spring-kafka-test")
testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion")
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.stablecoin.payments.orchestrator.application.controller;

import jakarta.validation.constraints.NotBlank;

/**
* Request DTO for cancelling a payment.
*/
public record CancelPaymentRequest(
@NotBlank(message = "reason is required")
String reason
) {}
Comment on lines +8 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a length constraint on reason.

Without a @Size(max = ...) limit, clients could submit arbitrarily large cancellation reasons, potentially causing DB storage issues or log bloat downstream.

🛡️ Optional: Add max length constraint
 public record CancelPaymentRequest(
-        `@NotBlank`(message = "reason is required")
+        `@NotBlank`(message = "reason is required")
+        `@Size`(max = 500, message = "reason must not exceed 500 characters")
         String reason
 ) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/CancelPaymentRequest.java`
around lines 8 - 11, Add a maximum length constraint to the CancelPaymentRequest
record's reason field to prevent arbitrarily large inputs: update the reason
declaration in CancelPaymentRequest by adding a `@Size`(max =
<appropriate_length>) annotation alongside `@NotBlank` (choose a sensible max,
e.g. 255 or what your DB/logging supports) so validation will reject overly long
cancellation reasons before they reach persistence or logs.

Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
package com.stablecoin.payments.orchestrator.application.controller;

import com.stablecoin.payments.orchestrator.api.ApiError;
import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException;
import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.INTERNAL_ERROR;
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_CANCELLABLE;
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_FOUND;
import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.VALIDATION_ERROR;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY;

@Slf4j
Expand All @@ -35,6 +45,31 @@ public ApiError handleValidation(MethodArgumentNotValidException ex) {
"Invalid request content", errors);
}

@ResponseStatus(BAD_REQUEST)
@ExceptionHandler(HandlerMethodValidationException.class)
public ApiError handleMethodValidation(HandlerMethodValidationException ex) {
Map<String, List<String>> errors = ex.getParameterValidationResults().stream()
.flatMap(result -> result.getResolvableErrors().stream()
.map(error -> Map.entry(
resolveParameterName(result, error),
error.getDefaultMessage())))
.collect(Collectors.groupingBy(
Map.Entry::getKey,
Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
log.info("Method validation failed: {}", errors);
return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(),
"Invalid request content", errors);
}

private static String resolveParameterName(ParameterValidationResult result,
org.springframework.context.MessageSourceResolvable error) {
if (error instanceof FieldError fieldError) {
return fieldError.getField();
}
var paramName = result.getMethodParameter().getParameterName();
return paramName != null ? paramName : "unknown";
}

@ResponseStatus(BAD_REQUEST)
@ExceptionHandler(ConstraintViolationException.class)
public ApiError handleConstraintViolation(ConstraintViolationException ex) {
Expand All @@ -54,6 +89,20 @@ public ApiError handleIllegalArgument(IllegalArgumentException ex) {
return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), ex.getMessage());
}

@ResponseStatus(NOT_FOUND)
@ExceptionHandler(PaymentNotFoundException.class)
public ApiError handlePaymentNotFound(PaymentNotFoundException ex) {
log.info("Payment not found: {}", ex.getMessage());
return ApiError.of(PAYMENT_NOT_FOUND, NOT_FOUND.getReasonPhrase(), ex.getMessage());
}

@ResponseStatus(CONFLICT)
@ExceptionHandler(PaymentNotCancellableException.class)
public ApiError handlePaymentNotCancellable(PaymentNotCancellableException ex) {
log.info("Payment not cancellable: {}", ex.getMessage());
return ApiError.of(PAYMENT_NOT_CANCELLABLE, CONFLICT.getReasonPhrase(), ex.getMessage());
}

@ResponseStatus(UNPROCESSABLE_ENTITY)
@ExceptionHandler(IllegalStateException.class)
public ApiError handleInvalidState(IllegalStateException ex) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.stablecoin.payments.orchestrator.application.controller;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.math.BigDecimal;
import java.util.UUID;

/**
* Request DTO for initiating a cross-border payment.
*/
public record InitiatePaymentRequest(
@NotNull(message = "senderId is required")
UUID senderId,

@NotNull(message = "recipientId is required")
UUID recipientId,

@NotNull(message = "sourceAmount is required")
@DecimalMin(value = "0.01", message = "sourceAmount must be positive")
BigDecimal sourceAmount,
Comment on lines +20 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding an upper bound on sourceAmount.

Without @DecimalMax, clients could submit astronomically large values. Business rules should define a sensible ceiling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/InitiatePaymentRequest.java`
around lines 20 - 22, The sourceAmount field in InitiatePaymentRequest lacks an
upper bound; add a `@DecimalMax` annotation to enforce a sensible ceiling (or
reference a business-configured constant) and update the validation message
accordingly. Locate the sourceAmount declaration in the InitiatePaymentRequest
record/class and add a `@DecimalMax`(value = "<ceiling>", message = "sourceAmount
must be <= <ceiling>") (or wire the ceiling from configuration/business rules)
so extremely large values are rejected by validation.


@NotBlank(message = "sourceCurrency is required")
String sourceCurrency,

@NotBlank(message = "targetCurrency is required")
String targetCurrency,

@NotBlank(message = "sourceCountry is required")
String sourceCountry,

@NotBlank(message = "targetCountry is required")
String targetCountry
Comment on lines +24 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider ISO validation for currency and country codes.

sourceCurrency/targetCurrency should ideally conform to ISO 4217 (3-letter codes), and country codes to ISO 3166-1 alpha-2. A @Pattern annotation or custom validator could enforce this at the edge.

💡 Example: Pattern validation for currency codes
-        `@NotBlank`(message = "sourceCurrency is required")
+        `@NotBlank`(message = "sourceCurrency is required")
+        `@Pattern`(regexp = "^[A-Z]{3}$", message = "sourceCurrency must be a valid 3-letter currency code")
         String sourceCurrency,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@NotBlank(message = "sourceCurrency is required")
String sourceCurrency,
@NotBlank(message = "targetCurrency is required")
String targetCurrency,
@NotBlank(message = "sourceCountry is required")
String sourceCountry,
@NotBlank(message = "targetCountry is required")
String targetCountry
`@NotBlank`(message = "sourceCurrency is required")
`@Pattern`(regexp = "^[A-Z]{3}$", message = "sourceCurrency must be a valid 3-letter currency code")
String sourceCurrency,
`@NotBlank`(message = "targetCurrency is required")
String targetCurrency,
`@NotBlank`(message = "sourceCountry is required")
String sourceCountry,
`@NotBlank`(message = "targetCountry is required")
String targetCountry
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/InitiatePaymentRequest.java`
around lines 24 - 34, Update the InitiatePaymentRequest DTO to validate ISO
formats: add `@Pattern` annotations to sourceCurrency and targetCurrency enforcing
ISO 4217 (e.g., regex "^[A-Z]{3}$" or case-insensitive variant) and to
sourceCountry and targetCountry enforcing ISO 3166-1 alpha-2 (e.g.,
"^[A-Z]{2}$"); include clear validation messages for each field and ensure the
annotations are imported/used on the existing fields (sourceCurrency,
targetCurrency, sourceCountry, targetCountry) in the InitiatePaymentRequest
class, or implement an equivalent custom validator if you need more complex
logic (e.g., checking against a known list of codes).

) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.stablecoin.payments.orchestrator.application.controller;

import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

/**
* REST controller for payment lifecycle management.
* <p>
* Thin HTTP handler that delegates all business logic to {@link PaymentCommandHandler}.
*/
@Slf4j
@RestController
@RequestMapping("/v1/payments")
@RequiredArgsConstructor
public class PaymentController {

private final PaymentCommandHandler commandHandler;

/**
* Initiates a new cross-border payment.
* <p>
* Idempotent: returns 200 with existing payment if the same Idempotency-Key is reused.
* Returns 201 on first creation.
*/
@PostMapping
public ResponseEntity<PaymentResponse> initiatePayment(
@NotBlank(message = "Idempotency-Key header is required")
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody InitiatePaymentRequest request) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.info("POST /v1/payments idempotencyKey={}, senderId={}", idempotencyKey, request.senderId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stop logging raw idempotency keys and cancel reasons.

Both values are user-controlled. Idempotency-Key is a replay token, and reason is free text that can carry PII or secrets. Log stable identifiers like paymentId/correlation metadata instead, or redact/hash before emission.

Also applies to: 79-79

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentController.java`
at line 42, The current log statement in PaymentController (log.info("POST
/v1/payments idempotencyKey={}, senderId={}", idempotencyKey,
request.senderId())) and the similar one that logs request.reason() must not
emit raw, user-controlled idempotency keys or free-text reasons; update these
log calls to either (a) log stable internal identifiers (e.g., paymentId or a
correlationId) instead of idempotencyKey, or (b) redact or hash the
idempotencyKey/reason before logging (e.g., replace with a fixed token like
"<redacted>" or a one-way hash), and keep senderId or other non-sensitive
metadata as needed; locate the log usage in PaymentController and replace the
offending parameters accordingly (avoid logging request.reason() raw and do not
log idempotencyKey directly).


var result = commandHandler.initiatePayment(
idempotencyKey,
UUID.randomUUID(),
request.senderId(),
request.recipientId(),
request.sourceAmount(),
request.sourceCurrency(),
request.targetCurrency(),
request.sourceCountry(),
request.targetCountry()
);

var response = PaymentResponse.from(result.payment());
var status = result.replay() ? HttpStatus.OK : HttpStatus.CREATED;

return ResponseEntity.status(status).body(response);
}

/**
* Retrieves a payment by its ID.
*/
@GetMapping("/{paymentId}")
public PaymentResponse getPayment(@PathVariable UUID paymentId) {
log.info("GET /v1/payments/{}", paymentId);
return PaymentResponse.from(commandHandler.getPayment(paymentId));
}

/**
* Cancels a payment by sending a cancel signal to the workflow.
* Returns 200 if accepted, 409 if the payment is in a terminal state.
*/
@PostMapping("/{paymentId}/cancel")
public PaymentResponse cancelPayment(
@PathVariable UUID paymentId,
@Valid @RequestBody CancelPaymentRequest request) {
log.info("POST /v1/payments/{}/cancel reason={}", paymentId, request.reason());
return PaymentResponse.from(commandHandler.cancelPayment(paymentId, request.reason()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.stablecoin.payments.orchestrator.application.controller;

import com.stablecoin.payments.orchestrator.domain.model.Payment;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

/**
* Response DTO representing a payment in the API layer.
*/
public record PaymentResponse(
UUID paymentId,
String idempotencyKey,
UUID correlationId,
String state,
UUID senderId,
UUID recipientId,
BigDecimal sourceAmount,
String sourceCurrency,
String targetCurrency,
BigDecimal targetAmount,
String sourceCountry,
String targetCountry,
String failureReason,
Instant createdAt,
Instant updatedAt,
Instant expiresAt
) {

/**
* Maps a domain {@link Payment} aggregate to the API response DTO.
*/
public static PaymentResponse from(Payment payment) {
return new PaymentResponse(
payment.paymentId(),
payment.idempotencyKey(),
payment.correlationId(),
payment.state().name(),
payment.senderId(),
payment.recipientId(),
payment.sourceAmount().amount(),
payment.sourceCurrency(),
payment.targetCurrency(),
payment.targetAmount() != null ? payment.targetAmount().amount() : null,
payment.corridor().sourceCountry(),
payment.corridor().targetCountry(),
payment.failureReason(),
payment.createdAt(),
payment.updatedAt(),
payment.expiresAt()
);
}
Comment on lines +34 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if corridor can be null in Payment domain
ast-grep --pattern $'class Payment {
  $$$
  Corridor corridor$$$
  $$$
}'

# Also check record definition
rg -A5 'record Payment\(' --type=java

Repository: Puneethkumarck/stablebridge-platform

Length of output: 950


🏁 Script executed:

cd payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model

# First, check the file size
wc -l Payment.java

# Then read the complete record definition
cat -n Payment.java | head -80

Repository: Puneethkumarck/stablebridge-platform

Length of output: 5089


🏁 Script executed:

# Read the rest of Payment.java to check for compact constructor or validation
cat -n payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/Payment.java | sed -n '66,150p'

Repository: Puneethkumarck/stablebridge-platform

Length of output: 5541


🏁 Script executed:

# Check for compact constructor in Payment record
cat -n payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/Payment.java | grep -A10 "public Payment"

# Also check how PaymentResponse.from() is called
rg -B3 -A3 'PaymentResponse\.from\(' --type=java

Repository: Puneethkumarck/stablebridge-platform

Length of output: 14180


Add null-safety for corridor() access in mapper.

corridor field lacks @NonNull and has no compact constructor validation. While Payment.initiate() validates, the Lombok builder permits null values if invoked directly. PaymentResponse.from() will throw NPE when accessing corridor().sourceCountry() if a Payment is constructed via builder() instead of the factory method.

Guard both corridor accesses:

payment.corridor() != null ? payment.corridor().sourceCountry() : null,
payment.corridor() != null ? payment.corridor().targetCountry() : null,

Alternatively, consider adding a compact constructor to Payment to enforce corridor non-nullability at the record level.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentResponse.java`
around lines 34 - 53, PaymentResponse.from currently dereferences
payment.corridor() which can be null when Payment is built via the Lombok
builder; update the mapper to null-guard both corridor accesses (the expressions
that call payment.corridor().sourceCountry() and
payment.corridor().targetCountry()) and return null for those fields when
corridor() is null. Locate the PaymentResponse.from(...) method and replace the
direct corridor().sourceCountry()/targetCountry() calls with ternary-style null
checks (or use Optional.ofNullable(payment.corridor()).map(...).orElse(null)) to
avoid NPEs; alternatively, enforce corridor non-null at the Payment record level
via a compact constructor in Payment if you prefer validation at construction
time.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.stablecoin.payments.orchestrator.domain.model;

import java.util.UUID;

/**
* Thrown when attempting to cancel a payment that is in a terminal state
* and cannot be cancelled.
*/
public class PaymentNotCancellableException extends RuntimeException {

public PaymentNotCancellableException(UUID paymentId, PaymentState state) {
super("Payment %s cannot be cancelled in state %s".formatted(paymentId, state));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stablecoin.payments.orchestrator.domain.model;

import java.util.UUID;

/**
* Thrown when a payment cannot be found by its ID.
*/
public class PaymentNotFoundException extends RuntimeException {

public PaymentNotFoundException(UUID paymentId) {
super("Payment not found: " + paymentId);
}
}
Loading
Loading