From 455181c8c4896bc855aac5078accd6884d7c90ad Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sun, 8 Mar 2026 22:27:42 +0100 Subject: [PATCH] feat(s3): application controllers + CollectionCommandHandler (STA-127) 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 --- .../onramp/api/CollectionRequest.java | 24 ++ .../onramp/client/FiatOnRampClient.java | 4 + .../controller/CollectionController.java | 140 ++++++++++++ .../controller/GlobalExceptionHandler.java | 107 +++++++++ .../service/CollectionCommandHandler.java | 133 +++++++++++ .../domain/service/CollectionResult.java | 9 + .../service/CollectionCommandHandlerTest.java | 207 ++++++++++++++++++ 7 files changed, 624 insertions(+) create mode 100644 fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/CollectionRequest.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/CollectionController.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionResult.java create mode 100644 fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java diff --git a/fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/CollectionRequest.java b/fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/CollectionRequest.java new file mode 100644 index 00000000..7131e5bf --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/CollectionRequest.java @@ -0,0 +1,24 @@ +package com.stablecoin.payments.onramp.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.util.UUID; + +public record CollectionRequest( + @NotNull UUID paymentId, + @NotNull UUID correlationId, + @NotNull @Positive BigDecimal amount, + @NotBlank String currency, + @NotBlank String paymentRailType, + @NotBlank String railCountry, + @NotBlank String railCurrency, + @NotBlank String pspId, + @NotBlank String pspName, + @NotBlank String senderAccountHash, + @NotBlank String senderBankCode, + @NotBlank String senderAccountType, + @NotBlank String senderAccountCountry +) {} diff --git a/fiat-on-ramp/fiat-on-ramp-client/src/main/java/com/stablecoin/payments/onramp/client/FiatOnRampClient.java b/fiat-on-ramp/fiat-on-ramp-client/src/main/java/com/stablecoin/payments/onramp/client/FiatOnRampClient.java index 2de76cc0..d8417f51 100644 --- a/fiat-on-ramp/fiat-on-ramp-client/src/main/java/com/stablecoin/payments/onramp/client/FiatOnRampClient.java +++ b/fiat-on-ramp/fiat-on-ramp-client/src/main/java/com/stablecoin/payments/onramp/client/FiatOnRampClient.java @@ -1,5 +1,6 @@ package com.stablecoin.payments.onramp.client; +import com.stablecoin.payments.onramp.api.CollectionRequest; import com.stablecoin.payments.onramp.api.CollectionResponse; import com.stablecoin.payments.onramp.api.RefundRequest; import com.stablecoin.payments.onramp.api.RefundResponse; @@ -15,6 +16,9 @@ @FeignClient(name = "fiat-on-ramp-service", url = "${app.services.fiat-on-ramp.url}") public interface FiatOnRampClient { + @PostMapping(value = "/v1/collections", consumes = "application/json", produces = "application/json") + CollectionResponse initiateCollection(@RequestBody CollectionRequest request); + @GetMapping(value = "/v1/collections/{collectionId}", produces = "application/json") CollectionResponse getCollection(@PathVariable("collectionId") UUID collectionId); diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/CollectionController.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/CollectionController.java new file mode 100644 index 00000000..1bdd9d1d --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/CollectionController.java @@ -0,0 +1,140 @@ +package com.stablecoin.payments.onramp.application.controller; + +import com.stablecoin.payments.onramp.api.CollectionRequest; +import com.stablecoin.payments.onramp.api.CollectionResponse; +import com.stablecoin.payments.onramp.api.RefundRequest; +import com.stablecoin.payments.onramp.api.RefundResponse; +import com.stablecoin.payments.onramp.domain.model.AccountType; +import com.stablecoin.payments.onramp.domain.model.BankAccount; +import com.stablecoin.payments.onramp.domain.model.CollectionOrder; +import com.stablecoin.payments.onramp.domain.model.Money; +import com.stablecoin.payments.onramp.domain.model.PaymentRail; +import com.stablecoin.payments.onramp.domain.model.PaymentRailType; +import com.stablecoin.payments.onramp.domain.model.PspIdentifier; +import com.stablecoin.payments.onramp.domain.model.Refund; +import com.stablecoin.payments.onramp.domain.service.CollectionCommandHandler; +import com.stablecoin.payments.onramp.domain.service.RefundCommandHandler; +import jakarta.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * REST controller for collection order lifecycle management. + *

+ * Thin HTTP handler that delegates all business logic to + * {@link CollectionCommandHandler} and {@link RefundCommandHandler}. + */ +@Slf4j +@RestController +@RequestMapping("/v1/collections") +@RequiredArgsConstructor +public class CollectionController { + + private final CollectionCommandHandler collectionCommandHandler; + private final RefundCommandHandler refundCommandHandler; + + /** + * Initiates a new collection order. + *

+ * Idempotent: returns 200 OK with the existing order if the same paymentId + * is submitted again. Returns 201 CREATED on first creation. + */ + @PostMapping + public ResponseEntity initiateCollection( + @Valid @RequestBody CollectionRequest request) { + log.info("POST /v1/collections paymentId={}", request.paymentId()); + + var amount = new Money(request.amount(), request.currency()); + var paymentRail = new PaymentRail( + PaymentRailType.valueOf(request.paymentRailType()), + request.railCountry(), + request.railCurrency()); + var psp = new PspIdentifier(request.pspId(), request.pspName()); + var senderAccount = new BankAccount( + request.senderAccountHash(), + request.senderBankCode(), + AccountType.valueOf(request.senderAccountType()), + request.senderAccountCountry()); + + var result = collectionCommandHandler.initiateCollection( + request.paymentId(), + request.correlationId(), + amount, + paymentRail, + psp, + senderAccount); + + var response = toCollectionResponse(result.order()); + var status = result.created() ? HttpStatus.CREATED : HttpStatus.OK; + + return ResponseEntity.status(status).body(response); + } + + /** + * Retrieves a collection order by its ID. + */ + @GetMapping("/{collectionId}") + public CollectionResponse getCollection(@PathVariable UUID collectionId) { + log.info("GET /v1/collections/{}", collectionId); + return toCollectionResponse(collectionCommandHandler.getCollection(collectionId)); + } + + /** + * Retrieves a collection order by its associated payment ID. + */ + @GetMapping + public CollectionResponse getCollectionByPaymentId(@RequestParam UUID paymentId) { + log.info("GET /v1/collections?paymentId={}", paymentId); + return toCollectionResponse(collectionCommandHandler.getCollectionByPaymentId(paymentId)); + } + + /** + * Initiates a refund for a collected order. + */ + @PostMapping("/{collectionId}/refunds") + public ResponseEntity initiateRefund( + @PathVariable UUID collectionId, + @Valid @RequestBody RefundRequest request) { + log.info("POST /v1/collections/{}/refunds", collectionId); + + var refundAmount = new Money(request.refundAmount(), request.currency()); + var refund = refundCommandHandler.initiateRefund(collectionId, refundAmount, request.reason()); + + return ResponseEntity.status(HttpStatus.CREATED).body(toRefundResponse(refund)); + } + + private CollectionResponse toCollectionResponse(CollectionOrder order) { + return new CollectionResponse( + order.collectionId(), + order.paymentId(), + order.status().name(), + order.paymentRail().rail().name(), + order.psp().pspName(), + order.pspReference(), + order.pspSettledAt(), + order.createdAt(), + order.expiresAt()); + } + + private RefundResponse toRefundResponse(Refund refund) { + return new RefundResponse( + refund.refundId(), + refund.collectionId(), + refund.status().name(), + refund.refundAmount().amount(), + refund.refundAmount().currency(), + refund.initiatedAt(), + refund.completedAt()); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java new file mode 100644 index 00000000..cc1f6857 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java @@ -0,0 +1,107 @@ +package com.stablecoin.payments.onramp.application.controller; + +import com.stablecoin.payments.onramp.api.ApiError; +import com.stablecoin.payments.onramp.domain.exception.CollectionOrderNotFoundException; +import com.stablecoin.payments.onramp.domain.exception.RefundAmountExceededException; +import com.stablecoin.payments.onramp.domain.exception.RefundNotAllowedException; +import com.stablecoin.payments.onramp.domain.exception.RefundNotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +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.MethodArgumentTypeMismatchException; + +import java.util.stream.Collectors; + +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; + +/** + * Global exception handler for the Fiat On-Ramp service. + *

+ * Maps domain exceptions to appropriate HTTP responses with error codes. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ApiError handleValidation(MethodArgumentNotValidException ex) { + var errors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.groupingBy( + FieldError::getField, + Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); + log.info("Validation failed: {}", errors); + return ApiError.withErrors("OR-0001", BAD_REQUEST.getReasonPhrase(), + "Invalid request content", errors); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); + return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(), + "Invalid value for parameter '" + ex.getName() + "'"); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(CollectionOrderNotFoundException.class) + public ApiError handleCollectionNotFound(CollectionOrderNotFoundException ex) { + log.info("Collection order not found: {}", ex.getMessage()); + return ApiError.of(CollectionOrderNotFoundException.ERROR_CODE, NOT_FOUND.getReasonPhrase(), + ex.getMessage()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(RefundNotFoundException.class) + public ApiError handleRefundNotFound(RefundNotFoundException ex) { + log.info("Refund not found: {}", ex.getMessage()); + return ApiError.of(RefundNotFoundException.ERROR_CODE, NOT_FOUND.getReasonPhrase(), + ex.getMessage()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(RefundNotAllowedException.class) + public ApiError handleRefundNotAllowed(RefundNotAllowedException ex) { + log.info("Refund not allowed: {}", ex.getMessage()); + return ApiError.of(RefundNotAllowedException.ERROR_CODE, CONFLICT.getReasonPhrase(), + ex.getMessage()); + } + + @ResponseStatus(UNPROCESSABLE_ENTITY) + @ExceptionHandler(RefundAmountExceededException.class) + public ApiError handleRefundAmountExceeded(RefundAmountExceededException ex) { + log.info("Refund amount exceeded: {}", ex.getMessage()); + return ApiError.of(RefundAmountExceededException.ERROR_CODE, UNPROCESSABLE_ENTITY.getReasonPhrase(), + ex.getMessage()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(IllegalStateException.class) + public ApiError handleIllegalState(IllegalStateException ex) { + log.info("Invalid state transition: {}", ex.getMessage()); + return ApiError.of("OR-0002", CONFLICT.getReasonPhrase(), ex.getMessage()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ApiError handleIllegalArgument(IllegalArgumentException ex) { + log.info("Illegal argument: {}", ex.getMessage()); + return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage()); + } + + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ApiError handleUnexpected(Exception ex) { + log.error("Unexpected error: ", ex); + return ApiError.of("OR-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), + INTERNAL_SERVER_ERROR.getReasonPhrase()); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java new file mode 100644 index 00000000..368dfd9e --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java @@ -0,0 +1,133 @@ +package com.stablecoin.payments.onramp.domain.service; + +import com.stablecoin.payments.onramp.domain.event.CollectionInitiatedEvent; +import com.stablecoin.payments.onramp.domain.exception.CollectionOrderNotFoundException; +import com.stablecoin.payments.onramp.domain.model.BankAccount; +import com.stablecoin.payments.onramp.domain.model.CollectionOrder; +import com.stablecoin.payments.onramp.domain.model.Money; +import com.stablecoin.payments.onramp.domain.model.PaymentRail; +import com.stablecoin.payments.onramp.domain.model.PspIdentifier; +import com.stablecoin.payments.onramp.domain.model.PspTransaction; +import com.stablecoin.payments.onramp.domain.model.PspTransactionDirection; +import com.stablecoin.payments.onramp.domain.port.CollectionEventPublisher; +import com.stablecoin.payments.onramp.domain.port.CollectionOrderRepository; +import com.stablecoin.payments.onramp.domain.port.PspGateway; +import com.stablecoin.payments.onramp.domain.port.PspPaymentRequest; +import com.stablecoin.payments.onramp.domain.port.PspTransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.UUID; + +/** + * Domain command handler for collection order operations. + *

+ * Orchestrates: idempotency check -> create order -> call PSP -> + * transition state -> record PspTransaction -> publish event -> save. + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class CollectionCommandHandler { + + private final CollectionOrderRepository collectionOrderRepository; + private final PspTransactionRepository pspTransactionRepository; + private final PspGateway pspGateway; + private final CollectionEventPublisher eventPublisher; + + /** + * Initiates a new collection order for a payment. + *

+ * Idempotent: if a collection order already exists for the given paymentId, + * returns the existing order with {@code created = false}. + * + * @param paymentId the payment identifier + * @param correlationId the correlation identifier for tracing + * @param amount the amount to collect + * @param paymentRail the payment rail details + * @param psp the PSP identifier + * @param senderAccount the sender bank account details + * @return a {@link CollectionResult} indicating the order and whether it was newly created + */ + public CollectionResult initiateCollection(UUID paymentId, UUID correlationId, + Money amount, PaymentRail paymentRail, + PspIdentifier psp, BankAccount senderAccount) { + // 1. Idempotency: check if collection order already exists for this paymentId + var existing = collectionOrderRepository.findByPaymentId(paymentId); + if (existing.isPresent()) { + log.info("Collection order already exists for paymentId={} collectionId={} status={}", + paymentId, existing.get().collectionId(), existing.get().status()); + return new CollectionResult(existing.get(), false); + } + + // 2. Create new collection order in PENDING state + var order = CollectionOrder.initiate(paymentId, correlationId, amount, paymentRail, psp, senderAccount); + + // 3. Call PSP to initiate payment + var pspResult = pspGateway.initiatePayment(new PspPaymentRequest( + order.collectionId(), amount, paymentRail, senderAccount, psp.pspName())); + + // 4. Transition: PENDING -> PAYMENT_INITIATED -> AWAITING_CONFIRMATION + order = order.initiatePayment(); + order = order.awaitConfirmation(pspResult.pspReference()); + + // 5. Record PspTransaction + var pspTransaction = PspTransaction.create( + order.collectionId(), + psp.pspName(), + pspResult.pspReference(), + PspTransactionDirection.DEBIT, + "payment_intent.created", + amount, + pspResult.status(), + null); + pspTransactionRepository.save(pspTransaction); + + // 6. Publish CollectionInitiatedEvent via outbox + eventPublisher.publish(new CollectionInitiatedEvent( + order.collectionId(), + paymentId, + correlationId, + amount.amount(), + amount.currency(), + paymentRail.rail().name(), + psp.pspName(), + Instant.now())); + + // 7. Save and return + order = collectionOrderRepository.save(order); + + log.info("Collection initiated collectionId={} paymentId={} pspRef={}", + order.collectionId(), paymentId, pspResult.pspReference()); + + return new CollectionResult(order, true); + } + + /** + * Retrieves a collection order by its ID. + * + * @param collectionId the collection order identifier + * @return the collection order + * @throws CollectionOrderNotFoundException if the order is not found + */ + public CollectionOrder getCollection(UUID collectionId) { + return collectionOrderRepository.findById(collectionId) + .orElseThrow(() -> new CollectionOrderNotFoundException(collectionId)); + } + + /** + * Retrieves a collection order by its associated payment ID. + * + * @param paymentId the payment identifier + * @return the collection order + * @throws CollectionOrderNotFoundException if the order is not found + */ + public CollectionOrder getCollectionByPaymentId(UUID paymentId) { + return collectionOrderRepository.findByPaymentId(paymentId) + .orElseThrow(() -> new CollectionOrderNotFoundException(paymentId)); + } +} diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionResult.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionResult.java new file mode 100644 index 00000000..336b90ee --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionResult.java @@ -0,0 +1,9 @@ +package com.stablecoin.payments.onramp.domain.service; + +import com.stablecoin.payments.onramp.domain.model.CollectionOrder; + +/** + * Wrapper returned by {@link CollectionCommandHandler#initiateCollection} + * so the controller can distinguish 201 CREATED vs 200 OK (idempotent replay). + */ +public record CollectionResult(CollectionOrder order, boolean created) {} diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java new file mode 100644 index 00000000..738a4ec7 --- /dev/null +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java @@ -0,0 +1,207 @@ +package com.stablecoin.payments.onramp.domain.service; + +import com.stablecoin.payments.onramp.domain.event.CollectionInitiatedEvent; +import com.stablecoin.payments.onramp.domain.exception.CollectionOrderNotFoundException; +import com.stablecoin.payments.onramp.domain.model.CollectionOrder; +import com.stablecoin.payments.onramp.domain.model.PspTransaction; +import com.stablecoin.payments.onramp.domain.model.PspTransactionDirection; +import com.stablecoin.payments.onramp.domain.port.CollectionEventPublisher; +import com.stablecoin.payments.onramp.domain.port.CollectionOrderRepository; +import com.stablecoin.payments.onramp.domain.port.PspGateway; +import com.stablecoin.payments.onramp.domain.port.PspPaymentRequest; +import com.stablecoin.payments.onramp.domain.port.PspPaymentResult; +import com.stablecoin.payments.onramp.domain.port.PspTransactionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.CORRELATION_ID; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.PAYMENT_ID; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.PSP_REFERENCE; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aBankAccount; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aCollectedOrder; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aMoney; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPaymentRail; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPendingOrder; +import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPspIdentifier; +import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring; +import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CollectionCommandHandler") +class CollectionCommandHandlerTest { + + @Mock private CollectionOrderRepository collectionOrderRepository; + @Mock private PspTransactionRepository pspTransactionRepository; + @Mock private PspGateway pspGateway; + @Mock private CollectionEventPublisher eventPublisher; + + private CollectionCommandHandler handler; + + @BeforeEach + void setUp() { + handler = new CollectionCommandHandler(collectionOrderRepository, pspTransactionRepository, + pspGateway, eventPublisher); + } + + @Nested + @DisplayName("initiateCollection") + class InitiateCollection { + + @Test + @DisplayName("should initiate collection successfully — saves AWAITING_CONFIRMATION order") + void shouldInitiateCollectionSuccessfully() { + // given + var amount = aMoney(); + var paymentRail = aPaymentRail(); + var psp = aPspIdentifier(); + var senderAccount = aBankAccount(); + var pspResult = new PspPaymentResult(PSP_REFERENCE, "requires_action"); + + var expectedOrder = CollectionOrder.initiate( + PAYMENT_ID, CORRELATION_ID, amount, paymentRail, psp, senderAccount) + .initiatePayment() + .awaitConfirmation(PSP_REFERENCE); + + var expectedPspPaymentRequest = new PspPaymentRequest( + expectedOrder.collectionId(), amount, paymentRail, senderAccount, psp.pspName()); + + var expectedPspTransaction = PspTransaction.create( + expectedOrder.collectionId(), + psp.pspName(), + PSP_REFERENCE, + PspTransactionDirection.DEBIT, + "payment_intent.created", + amount, + "requires_action", + null); + + var expectedEvent = new CollectionInitiatedEvent( + expectedOrder.collectionId(), + PAYMENT_ID, + CORRELATION_ID, + amount.amount(), + amount.currency(), + paymentRail.rail().name(), + psp.pspName(), + Instant.now()); + + given(collectionOrderRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(pspGateway.initiatePayment(eqIgnoring(expectedPspPaymentRequest, "collectionId"))) + .willReturn(pspResult); + given(collectionOrderRepository.save(eqIgnoring(expectedOrder, "collectionId"))) + .willReturn(expectedOrder); + + // when + handler.initiateCollection(PAYMENT_ID, CORRELATION_ID, amount, paymentRail, psp, senderAccount); + + // then + then(collectionOrderRepository).should().save(eqIgnoring(expectedOrder, "collectionId")); + then(pspTransactionRepository).should().save(eqIgnoring(expectedPspTransaction, "collectionId", "pspTxnId")); + then(eventPublisher).should().publish(eqIgnoring(expectedEvent, "collectionId")); + } + + @Test + @DisplayName("should return existing order when paymentId already exists — idempotent") + void shouldReturnExistingOrderWhenPaymentIdAlreadyExists() { + // given + var existingOrder = aCollectedOrder(); + var amount = aMoney(); + var paymentRail = aPaymentRail(); + var psp = aPspIdentifier(); + var senderAccount = aBankAccount(); + + given(collectionOrderRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.of(existingOrder)); + + // when + handler.initiateCollection(PAYMENT_ID, CORRELATION_ID, amount, paymentRail, psp, senderAccount); + + // then — no save or PSP calls should occur + then(collectionOrderRepository).should(never()).save(eqIgnoringTimestamps(existingOrder)); + then(pspGateway).shouldHaveNoInteractions(); + then(pspTransactionRepository).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("getCollection") + class GetCollection { + + @Test + @DisplayName("should get collection by id") + void shouldGetCollectionById() { + // given + var order = aPendingOrder(); + var collectionId = order.collectionId(); + + given(collectionOrderRepository.findById(collectionId)).willReturn(Optional.of(order)); + + // when + handler.getCollection(collectionId); + + // then + then(collectionOrderRepository).should().findById(collectionId); + } + + @Test + @DisplayName("should throw when collection not found") + void shouldThrowWhenCollectionNotFound() { + // given + var collectionId = UUID.randomUUID(); + + given(collectionOrderRepository.findById(collectionId)).willReturn(Optional.empty()); + + // when/then + assertThatThrownBy(() -> handler.getCollection(collectionId)) + .isInstanceOf(CollectionOrderNotFoundException.class) + .hasMessageContaining(collectionId.toString()); + } + } + + @Nested + @DisplayName("getCollectionByPaymentId") + class GetCollectionByPaymentId { + + @Test + @DisplayName("should get collection by payment id") + void shouldGetCollectionByPaymentId() { + // given + var order = aPendingOrder(); + + given(collectionOrderRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.of(order)); + + // when + handler.getCollectionByPaymentId(PAYMENT_ID); + + // then + then(collectionOrderRepository).should().findByPaymentId(PAYMENT_ID); + } + + @Test + @DisplayName("should throw when collection not found by payment id") + void shouldThrowWhenCollectionNotFoundByPaymentId() { + // given + var paymentId = UUID.randomUUID(); + + given(collectionOrderRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); + + // when/then + assertThatThrownBy(() -> handler.getCollectionByPaymentId(paymentId)) + .isInstanceOf(CollectionOrderNotFoundException.class) + .hasMessageContaining(paymentId.toString()); + } + } +}