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
+ * 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());
+ }
+ }
+}