diff --git a/kafka/connects/debezium-checkout-status.json b/kafka/connects/debezium-checkout-status.json new file mode 100644 index 0000000000..96bdf06300 --- /dev/null +++ b/kafka/connects/debezium-checkout-status.json @@ -0,0 +1,16 @@ +{ + "connector.class": "io.debezium.connector.postgresql.PostgresConnector", + "topic.prefix": "dbcheckout-status", + "database.user": "admin", + "database.dbname": "order", + "database.hostname": "postgres", + "database.password": "admin", + "database.port": "5432", + "key.converter.schemas.enable": "false", + "value.converter.schemas.enable": "false", + "value.converter": "org.apache.kafka.connect.json.JsonConverter", + "key.converter": "org.apache.kafka.connect.json.JsonConverter", + "schema.include.list": "public", + "table.include.list": "public.checkout", + "slot.name": "checkout_status_slot" +} diff --git a/kafka/connects/debezium-payment.json b/kafka/connects/debezium-payment.json new file mode 100644 index 0000000000..3ccd1e9465 --- /dev/null +++ b/kafka/connects/debezium-payment.json @@ -0,0 +1,16 @@ +{ + "connector.class": "io.debezium.connector.postgresql.PostgresConnector", + "topic.prefix": "dbpayment", + "database.user": "admin", + "database.dbname": "payment", + "database.hostname": "postgres", + "database.password": "admin", + "database.port": "5432", + "key.converter.schemas.enable": "false", + "value.converter.schemas.enable": "false", + "value.converter": "org.apache.kafka.connect.json.JsonConverter", + "key.converter": "org.apache.kafka.connect.json.JsonConverter", + "schema.include.list": "public", + "table.include.list": "public.payment", + "slot.name": "payment_slot" +} diff --git a/order/pom.xml b/order/pom.xml index aa3f863f96..83bb68d8fe 100644 --- a/order/pom.xml +++ b/order/pom.xml @@ -29,6 +29,14 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.kafka + spring-kafka + + + com.google.code.gson + gson + org.springframework.boot spring-boot-starter-data-jpa @@ -51,6 +59,11 @@ org.liquibase liquibase-core + + org.testcontainers + kafka + test + com.yas common-library diff --git a/order/src/it/java/com/yas/order/service/CustomerServiceIT.java b/order/src/it/java/com/yas/order/service/CustomerServiceIT.java index f759051d11..4b08f63a73 100644 --- a/order/src/it/java/com/yas/order/service/CustomerServiceIT.java +++ b/order/src/it/java/com/yas/order/service/CustomerServiceIT.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; +import com.yas.order.config.KafkaIntegrationTestConfiguration; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; import org.junit.jupiter.api.Test; @@ -13,12 +14,14 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Testcontainers +@Import({KafkaIntegrationTestConfiguration.class}) class CustomerServiceIT { @Container @ServiceConnection diff --git a/order/src/it/java/com/yas/order/service/OrderServiceIT.java b/order/src/it/java/com/yas/order/service/OrderServiceIT.java index d64c4ba3b5..14e1e84687 100644 --- a/order/src/it/java/com/yas/order/service/OrderServiceIT.java +++ b/order/src/it/java/com/yas/order/service/OrderServiceIT.java @@ -9,6 +9,7 @@ import com.yas.commonlibrary.IntegrationTestConfiguration; import com.yas.commonlibrary.exception.NotFoundException; import com.yas.order.OrderApplication; +import com.yas.order.config.KafkaIntegrationTestConfiguration; import com.yas.order.model.Order; import com.yas.order.model.enumeration.OrderStatus; import com.yas.order.model.enumeration.PaymentStatus; @@ -37,7 +38,7 @@ import org.springframework.data.util.Pair; @SpringBootTest(classes = OrderApplication.class) -@Import(IntegrationTestConfiguration.class) +@Import({IntegrationTestConfiguration.class, KafkaIntegrationTestConfiguration.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class OrderServiceIT { diff --git a/order/src/it/java/com/yas/order/service/ProductServiceIT.java b/order/src/it/java/com/yas/order/service/ProductServiceIT.java index 9255bb95e8..dabd957ba2 100644 --- a/order/src/it/java/com/yas/order/service/ProductServiceIT.java +++ b/order/src/it/java/com/yas/order/service/ProductServiceIT.java @@ -6,9 +6,9 @@ import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; +import com.yas.order.config.KafkaIntegrationTestConfiguration; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; -import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -16,12 +16,14 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Testcontainers +@Import({KafkaIntegrationTestConfiguration.class}) class ProductServiceIT { @Container @ServiceConnection diff --git a/order/src/it/java/com/yas/order/service/TaxServiceIT.java b/order/src/it/java/com/yas/order/service/TaxServiceIT.java index 2f0edd6cdb..67cff8dffd 100644 --- a/order/src/it/java/com/yas/order/service/TaxServiceIT.java +++ b/order/src/it/java/com/yas/order/service/TaxServiceIT.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; +import com.yas.order.config.KafkaIntegrationTestConfiguration; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; import org.junit.jupiter.api.Test; @@ -13,12 +14,14 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Testcontainers +@Import({KafkaIntegrationTestConfiguration.class}) class TaxServiceIT { @Container @ServiceConnection diff --git a/order/src/it/resources/application.properties b/order/src/it/resources/application.properties index 1d72b33cea..7c6e1c0ba3 100644 --- a/order/src/it/resources/application.properties +++ b/order/src/it/resources/application.properties @@ -12,4 +12,21 @@ spring.security.oauth2.resourceserver.jwt.issuer-uri=test springdoc.oauthflow.authorization-url=test springdoc.oauthflow.token-url=test spring.jpa.open-in-view=true -cors.allowed-origins=* \ No newline at end of file +cors.allowed-origins=* + +cdc.event.checkout.status.topic-name=dbcheckout-status.public.checkout +cdc.event.checkout.status.group-id=checkout-status + +cdc.event.payment.topic-name=dbpayment.public.payment +cdc.event.payment.update.group-id=payment-update + +kafka.version=7.0.9 + +spring.kafka.consumer.bootstrap-servers=kafka:9092 +spring.aop.proxy-target-class=true + +spring.kafka.producer.bootstrap-servers=kafka:9092 +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer + +spring.kafka.bootstrap-servers=localhost:9092 \ No newline at end of file diff --git a/order/src/main/java/com/yas/order/config/JsonConfig.java b/order/src/main/java/com/yas/order/config/JsonConfig.java new file mode 100644 index 0000000000..d6478dc5cc --- /dev/null +++ b/order/src/main/java/com/yas/order/config/JsonConfig.java @@ -0,0 +1,22 @@ +package com.yas.order.config; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class JsonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + public Gson gson() { + return new Gson(); + } +} \ No newline at end of file diff --git a/order/src/main/java/com/yas/order/config/ServiceUrlConfig.java b/order/src/main/java/com/yas/order/config/ServiceUrlConfig.java index 9c5c794291..8319dbf6ca 100644 --- a/order/src/main/java/com/yas/order/config/ServiceUrlConfig.java +++ b/order/src/main/java/com/yas/order/config/ServiceUrlConfig.java @@ -4,5 +4,5 @@ @ConfigurationProperties(prefix = "yas.services") public record ServiceUrlConfig( - String cart, String customer, String product, String tax, String promotion) { + String cart, String customer, String product, String tax, String promotion, String payment) { } diff --git a/order/src/main/java/com/yas/order/controller/CheckoutController.java b/order/src/main/java/com/yas/order/controller/CheckoutController.java index 655638e5a1..96a6bf0993 100644 --- a/order/src/main/java/com/yas/order/controller/CheckoutController.java +++ b/order/src/main/java/com/yas/order/controller/CheckoutController.java @@ -37,11 +37,16 @@ public ResponseEntity updateCheckoutStatus(@Valid @RequestBody CheckoutSta return ResponseEntity.ok(checkoutService.updateCheckoutStatus(checkoutStatusPutVm)); } - @GetMapping("/storefront/checkouts/{id}") - public ResponseEntity getOrderWithItemsById(@PathVariable String id) { + @GetMapping("/storefront/checkouts/pending/{id}") + public ResponseEntity getPendingCheckoutDetailsById(@PathVariable String id) { return ResponseEntity.ok(checkoutService.getCheckoutPendingStateWithItemsById(id)); } + @GetMapping("/storefront/checkouts/{id}") + public ResponseEntity getCheckoutById(@PathVariable String id) { + return ResponseEntity.ok(checkoutService.findCheckoutWithItemsById(id)); + } + @PutMapping("/storefront/checkouts/{id}/payment-method") @ApiResponses(value = { @ApiResponse(responseCode = ApiConstant.CODE_200, description = ApiConstant.OK, diff --git a/order/src/main/java/com/yas/order/kafka/AppKafkaListenerConfigurer.java b/order/src/main/java/com/yas/order/kafka/AppKafkaListenerConfigurer.java new file mode 100644 index 0000000000..f7053b5231 --- /dev/null +++ b/order/src/main/java/com/yas/order/kafka/AppKafkaListenerConfigurer.java @@ -0,0 +1,24 @@ +package com.yas.order.kafka; + +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.annotation.KafkaListenerConfigurer; +import org.springframework.kafka.config.KafkaListenerEndpointRegistrar; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@EnableKafka +@Configuration +public class AppKafkaListenerConfigurer implements KafkaListenerConfigurer { + + private LocalValidatorFactoryBean validator; + + public AppKafkaListenerConfigurer(LocalValidatorFactoryBean validator) { + this.validator = validator; + } + + @Override + public void configureKafkaListeners(KafkaListenerEndpointRegistrar registrar) { + // Enable message validation + registrar.setValidator(this.validator); + } +} diff --git a/order/src/main/java/com/yas/order/kafka/consumer/OrderStatusConsumer.java b/order/src/main/java/com/yas/order/kafka/consumer/OrderStatusConsumer.java new file mode 100644 index 0000000000..e575a1867c --- /dev/null +++ b/order/src/main/java/com/yas/order/kafka/consumer/OrderStatusConsumer.java @@ -0,0 +1,144 @@ +package com.yas.order.kafka.consumer; + +import static com.yas.order.utils.JsonUtils.convertObjectToString; +import static com.yas.order.utils.JsonUtils.createJsonErrorObject; +import static com.yas.order.utils.JsonUtils.getAttributesNode; +import static com.yas.order.utils.JsonUtils.getJsonValueOrThrow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.yas.commonlibrary.exception.BadRequestException; +import com.yas.commonlibrary.exception.NotFoundException; +import com.yas.order.model.Checkout; +import com.yas.order.model.enumeration.CheckoutProgress; +import com.yas.order.model.enumeration.CheckoutState; +import com.yas.order.repository.CheckoutRepository; +import com.yas.order.service.PaymentService; +import com.yas.order.utils.Constants; +import com.yas.order.viewmodel.payment.CheckoutPaymentVm; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderStatusConsumer { + + private static final Logger LOGGER = LoggerFactory.getLogger(OrderStatusConsumer.class); + private final PaymentService paymentService; + private final CheckoutRepository checkoutRepository; + private final ObjectMapper objectMapper; + private final Gson gson; + + @KafkaListener( + topics = "${cdc.event.checkout.status.topic-name}", + groupId = "${cdc.event.checkout.status.group-id}" + ) + @RetryableTopic( + attempts = "1" + ) + public void listen(ConsumerRecord consumerRecord) { + + if (Objects.isNull(consumerRecord)) { + LOGGER.info("ConsumerRecord is null"); + return; + } + JsonObject valueObject = gson.fromJson((String) consumerRecord.value(), JsonObject.class); + processCheckoutEvent(valueObject); + + } + + private void processCheckoutEvent(JsonObject valueObject) { + Optional.ofNullable(valueObject) + .filter(value -> value.has("after")) + .map(value -> value.getAsJsonObject("after")) + .ifPresent(this::handleAfterJson); + } + + private void handleAfterJson(JsonObject after) { + + String id = getJsonValueOrThrow(after, Constants.Column.ID_COLUMN, + Constants.ErrorCode.ID_NOT_EXISTED); + String status = getJsonValueOrThrow(after, Constants.Column.STATUS_COLUMN, + Constants.ErrorCode.STATUS_NOT_EXISTED, id); + String progress = getJsonValueOrThrow(after, Constants.Column.CHECKOUT_PROGRESS_COLUMN, + Constants.ErrorCode.PROGRESS_NOT_EXISTED, id); + + if (!isPaymentProcessing(status, progress)) { + LOGGER.info("Checkout record with ID {} lacks the status 'PAYMENT_PROCESSING' and progress 'STOCK_LOCKED'", + id); + return; + } + + LOGGER.info("Checkout record with ID {} has the status 'PAYMENT_PROCESSING' and the process 'STOCK_LOCKED'", + id); + + Checkout checkout = checkoutRepository + .findById(id) + .orElseThrow(() -> new NotFoundException(Constants.ErrorCode.CHECKOUT_NOT_FOUND, id)); + + processPaymentAndUpdateCheckout(checkout); + } + + private boolean isPaymentProcessing(String status, String process) { + return CheckoutState.PAYMENT_PROCESSING.name().equalsIgnoreCase(status) + && CheckoutProgress.STOCK_LOCKED.name().equalsIgnoreCase(process); + } + + private void processPaymentAndUpdateCheckout(Checkout checkout) { + + try { + Long paymentId = processPayment(checkout); + checkout.setProgress(CheckoutProgress.PAYMENT_CREATED); + checkout.setLastError(null); + + ObjectNode updatedAttributes = updateAttributesWithPayment(checkout.getAttributes(), paymentId); + checkout.setAttributes(convertObjectToString(objectMapper, updatedAttributes)); + + } catch (Exception e) { + + checkout.setProgress(CheckoutProgress.PAYMENT_CREATED_FAILED); + + ObjectNode error = createJsonErrorObject(objectMapper, CheckoutProgress.PAYMENT_CREATED_FAILED.name(), + e.getMessage()); + checkout.setLastError(convertObjectToString(objectMapper, error)); + + LOGGER.error(e.getMessage()); + throw new BadRequestException(Constants.ErrorCode.PROCESS_CHECKOUT_FAILED, checkout.getId()); + + } finally { + checkoutRepository.save(checkout); + } + } + + private Long processPayment(Checkout checkout) { + + CheckoutPaymentVm requestDto = new CheckoutPaymentVm( + checkout.getId(), + checkout.getPaymentMethodId(), + checkout.getTotalAmount() + ); + + Long paymentId = paymentService.createPaymentFromEvent(requestDto); + LOGGER.info("Payment created successfully with ID: {}", paymentId); + + return paymentId; + } + + private ObjectNode updateAttributesWithPayment(String attributes, Long paymentId) { + + ObjectNode attributesNode = getAttributesNode(objectMapper, attributes); + attributesNode.put(Constants.Column.CHECKOUT_ATTRIBUTES_PAYMENT_ID_FIELD, paymentId); + + return attributesNode; + } + +} diff --git a/order/src/main/java/com/yas/order/kafka/consumer/PaymentUpdateConsumer.java b/order/src/main/java/com/yas/order/kafka/consumer/PaymentUpdateConsumer.java new file mode 100644 index 0000000000..e3be435c6a --- /dev/null +++ b/order/src/main/java/com/yas/order/kafka/consumer/PaymentUpdateConsumer.java @@ -0,0 +1,108 @@ +package com.yas.order.kafka.consumer; + +import static com.yas.order.utils.JsonUtils.convertObjectToString; +import static com.yas.order.utils.JsonUtils.getAttributesNode; +import static com.yas.order.utils.JsonUtils.getJsonValueOrNull; +import static com.yas.order.utils.JsonUtils.getJsonValueOrThrow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.yas.order.model.Checkout; +import com.yas.order.model.enumeration.CheckoutProgress; +import com.yas.order.model.enumeration.CheckoutState; +import com.yas.order.service.CheckoutService; +import com.yas.order.utils.Constants; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PaymentUpdateConsumer { + + private static final Logger LOGGER = LoggerFactory.getLogger(PaymentUpdateConsumer.class); + private final CheckoutService checkoutService; + private final ObjectMapper objectMapper; + private final Gson gson; + + @KafkaListener( + topics = "${cdc.event.payment.topic-name}", + groupId = "${cdc.event.payment.update.group-id}" + ) + @RetryableTopic + public void listen(ConsumerRecord consumerRecord) { + + if (Objects.isNull(consumerRecord)) { + LOGGER.info("Consumer Record is null"); + return; + } + JsonObject valueObject = gson.fromJson((String) consumerRecord.value(), JsonObject.class); + processPaymentEvent(valueObject); + + } + + private void processPaymentEvent(JsonObject valueObject) { + Optional.ofNullable(valueObject) + .filter( + value -> value.has("op") && "u".equals(value.get("op").getAsString()) + ) + .filter(value -> value.has("before") && value.has("after")) + .ifPresent(this::handleJsonForUpdateCheckout); + } + + private void handleJsonForUpdateCheckout(JsonObject valueObject) { + + JsonObject before = valueObject.getAsJsonObject("before"); + JsonObject after = valueObject.getAsJsonObject("after"); + + String id = getJsonValueOrThrow(after, Constants.Column.ID_COLUMN, + Constants.ErrorCode.ID_NOT_EXISTED); + + String beforePaypalOrderId = getJsonValueOrNull(before, + Constants.Column.CHECKOUT_ATTRIBUTES_PAYMENT_PROVIDER_CHECKOUT_ID_FIELD); + String afterPaypalOrderId = getJsonValueOrNull(after, + Constants.Column.CHECKOUT_ATTRIBUTES_PAYMENT_PROVIDER_CHECKOUT_ID_FIELD); + + if (!Objects.isNull(afterPaypalOrderId) && !afterPaypalOrderId.equals(beforePaypalOrderId)) { + + LOGGER.info("Handle json for update Checkout with Payment {}", id); + + String checkoutId = getJsonValueOrThrow(after, Constants.Column.CHECKOUT_ID_COLUMN, + Constants.ErrorCode.CHECKOUT_ID_NOT_EXISTED); + updateCheckOut(checkoutId, afterPaypalOrderId); + } else { + LOGGER.warn("It is not an event to update an Order on PayPal with Payment ID {}", id); + } + } + + private void updateCheckOut(String checkoutId, String paymentProviderCheckoutId) { + + Checkout checkout = checkoutService.findCheckoutById(checkoutId); + checkout.setCheckoutState(CheckoutState.PAYMENT_PROCESSING); + checkout.setProgress(CheckoutProgress.PAYMENT_CREATED); + + ObjectNode updatedAttributes = updateAttributesWithCheckout(checkout.getAttributes(), + paymentProviderCheckoutId); + checkout.setAttributes(convertObjectToString(objectMapper, updatedAttributes)); + + checkoutService.updateCheckout(checkout); + } + + private ObjectNode updateAttributesWithCheckout(String attributes, String paymentProviderCheckoutId) { + + ObjectNode attributesNode = getAttributesNode(objectMapper, attributes); + attributesNode.put(Constants.Column.CHECKOUT_ATTRIBUTES_PAYMENT_PROVIDER_CHECKOUT_ID_FIELD, + paymentProviderCheckoutId); + + return attributesNode; + } + +} \ No newline at end of file diff --git a/order/src/main/java/com/yas/order/mapper/CheckoutMapper.java b/order/src/main/java/com/yas/order/mapper/CheckoutMapper.java index 54829ae941..e53f14c1c0 100644 --- a/order/src/main/java/com/yas/order/mapper/CheckoutMapper.java +++ b/order/src/main/java/com/yas/order/mapper/CheckoutMapper.java @@ -16,11 +16,22 @@ public interface CheckoutMapper { @Mapping(target = "id", ignore = true) @Mapping(target = "checkout", ignore = true) + @Mapping(target = "checkoutId", ignore = true) CheckoutItem toModel(CheckoutItemPostVm checkoutItemPostVm); @Mapping(target = "id", ignore = true) @Mapping(target = "checkoutState", ignore = true) - @Mapping(target = "totalAmount", source = "totalAmount") // Ánh xạ tường minh cho totalAmount + @Mapping(target = "progress", ignore = true) + @Mapping(target = "customerId", ignore = true) + @Mapping(target = "shipmentMethodId", ignore = true) + @Mapping(target = "paymentMethodId", ignore = true) + @Mapping(target = "shippingAddressId", ignore = true) + @Mapping(target = "lastError", ignore = true) + @Mapping(target = "attributes", ignore = true) + @Mapping(target = "totalShipmentFee", ignore = true) + @Mapping(target = "totalShipmentTax", ignore = true) + @Mapping(target = "totalTax", ignore = true) + @Mapping(target = "totalAmount", source = "totalAmount") @Mapping(target = "totalDiscountAmount", source = "totalDiscountAmount") Checkout toModel(CheckoutPostVm checkoutPostVm); diff --git a/order/src/main/java/com/yas/order/model/Checkout.java b/order/src/main/java/com/yas/order/model/Checkout.java index c2c555628f..4478783238 100644 --- a/order/src/main/java/com/yas/order/model/Checkout.java +++ b/order/src/main/java/com/yas/order/model/Checkout.java @@ -1,6 +1,8 @@ package com.yas.order.model; +import com.yas.order.model.enumeration.CheckoutProgress; import com.yas.order.model.enumeration.CheckoutState; +import com.yas.order.model.enumeration.PaymentMethod; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -39,8 +41,8 @@ public class Checkout extends AbstractAuditEntity { @Enumerated(EnumType.STRING) private CheckoutState checkoutState; - @SuppressWarnings("unused") - private String progress; + @Enumerated(EnumType.STRING) + private CheckoutProgress progress; @SuppressWarnings("unused") private Long customerId; @@ -48,13 +50,12 @@ public class Checkout extends AbstractAuditEntity { @SuppressWarnings("unused") private String shipmentMethodId; - @Column(name = "payment_method_id") - private String paymentMethodId; + @Enumerated(EnumType.STRING) + private PaymentMethod paymentMethodId; @SuppressWarnings("unused") private Long shippingAddressId; - @SuppressWarnings("unused") @JdbcTypeCode(SqlTypes.JSON) @Column(name = "last_error", columnDefinition = "jsonb") private String lastError; diff --git a/order/src/main/java/com/yas/order/model/enumeration/CheckoutProgress.java b/order/src/main/java/com/yas/order/model/enumeration/CheckoutProgress.java new file mode 100644 index 0000000000..5f21ca4535 --- /dev/null +++ b/order/src/main/java/com/yas/order/model/enumeration/CheckoutProgress.java @@ -0,0 +1,20 @@ +package com.yas.order.model.enumeration; + +public enum CheckoutProgress { + INIT("Init"), + PROMOTION_CODE_APPLIED("Promotion code applied"), + PROMOTION_CODE_APPLIED_FAILED("Promotion Code applied failed"), + STOCK_LOCKED("Stock locked"), + STOCK_LOCKED_FAILED("Stock locked failed"), + PAYMENT_CREATED("Payment created"), + PAYMENT_CREATED_FAILED("Payment created failed"); + private final String name; + + CheckoutProgress(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/order/src/main/java/com/yas/order/model/enumeration/CheckoutState.java b/order/src/main/java/com/yas/order/model/enumeration/CheckoutState.java index 22c2cec975..3bf0046bee 100644 --- a/order/src/main/java/com/yas/order/model/enumeration/CheckoutState.java +++ b/order/src/main/java/com/yas/order/model/enumeration/CheckoutState.java @@ -1,7 +1,15 @@ package com.yas.order.model.enumeration; public enum CheckoutState { - COMPLETED("Completed"), PENDING("Pending"), LOCK("LOCK"); + COMPLETED("Completed"), + PENDING("Pending"), + LOCK("LOCK"), + CHECKED_OUT("Checked Out"), + PAYMENT_PROCESSING("Payment Processing"), + PAYMENT_FAILED("Payment Failed"), + PAYMENT_CONFIRMED("Payment Confirmed"), + FULFILLED("Fulfilled") + ; private final String name; CheckoutState(String name) { diff --git a/order/src/main/java/com/yas/order/model/enumeration/PaymentMethod.java b/order/src/main/java/com/yas/order/model/enumeration/PaymentMethod.java index eb189d80b2..42ea52f8b9 100644 --- a/order/src/main/java/com/yas/order/model/enumeration/PaymentMethod.java +++ b/order/src/main/java/com/yas/order/model/enumeration/PaymentMethod.java @@ -1,5 +1,14 @@ package com.yas.order.model.enumeration; public enum PaymentMethod { - COD, BANKING, PAYPAL + COD, BANKING, PAYPAL; + + public static PaymentMethod fromValue(String value) { + try { + return PaymentMethod.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid payment method: " + value); + } + } } + diff --git a/order/src/main/java/com/yas/order/service/CheckoutService.java b/order/src/main/java/com/yas/order/service/CheckoutService.java index f8ab6b5169..d499a54dee 100644 --- a/order/src/main/java/com/yas/order/service/CheckoutService.java +++ b/order/src/main/java/com/yas/order/service/CheckoutService.java @@ -2,6 +2,7 @@ import static com.yas.order.utils.Constants.ErrorCode.CHECKOUT_NOT_FOUND; +import com.yas.commonlibrary.exception.BadRequestException; import com.yas.commonlibrary.exception.Forbidden; import com.yas.commonlibrary.exception.NotFoundException; import com.yas.order.mapper.CheckoutMapper; @@ -9,6 +10,7 @@ import com.yas.order.model.CheckoutItem; import com.yas.order.model.Order; import com.yas.order.model.enumeration.CheckoutState; +import com.yas.order.model.enumeration.PaymentMethod; import com.yas.order.repository.CheckoutItemRepository; import com.yas.order.repository.CheckoutRepository; import com.yas.order.utils.AuthenticationUtils; @@ -18,7 +20,10 @@ import com.yas.order.viewmodel.checkout.CheckoutPostVm; import com.yas.order.viewmodel.checkout.CheckoutStatusPutVm; import com.yas.order.viewmodel.checkout.CheckoutVm; +import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -104,9 +109,43 @@ public Long updateCheckoutStatus(CheckoutStatusPutVm checkoutStatusPutVm) { } public void updateCheckoutPaymentMethod(String id, CheckoutPaymentMethodPutVm checkoutPaymentMethodPutVm) { + + if (Objects.isNull(checkoutPaymentMethodPutVm.paymentMethodId())) { + return; + } + Checkout checkout = checkoutRepository.findById(id) .orElseThrow(() -> new NotFoundException(CHECKOUT_NOT_FOUND, id)); - checkout.setPaymentMethodId(checkoutPaymentMethodPutVm.paymentMethodId()); + checkout.setPaymentMethodId(PaymentMethod.fromValue(checkoutPaymentMethodPutVm.paymentMethodId())); + checkoutRepository.save(checkout); + } + + public Checkout findCheckoutById(String id) { + + return this.checkoutRepository.findById(id) + .orElseThrow(() -> new NotFoundException(CHECKOUT_NOT_FOUND, id)); + } + + public CheckoutVm findCheckoutWithItemsById(String id) { + + Checkout checkout = findCheckoutById(id); + + List checkoutItems = checkoutItemRepository.findAllByCheckoutId(checkout.getId()); + + Set checkoutItemVms = Optional.ofNullable(checkoutItems) + .orElse(Collections.emptyList()) + .stream() + .map(checkoutMapper::toVm) + .collect(Collectors.toSet()); + + return CheckoutVm.fromModel(checkout, checkoutItemVms); + } + + public void updateCheckout(Checkout checkout) { + + if (Objects.isNull(checkout.getId())) { + throw new BadRequestException(Constants.ErrorCode.ID_NOT_EXISTED); + } checkoutRepository.save(checkout); } } diff --git a/order/src/main/java/com/yas/order/service/PaymentService.java b/order/src/main/java/com/yas/order/service/PaymentService.java new file mode 100644 index 0000000000..0ee8334cf0 --- /dev/null +++ b/order/src/main/java/com/yas/order/service/PaymentService.java @@ -0,0 +1,42 @@ +package com.yas.order.service; + +import com.yas.order.config.ServiceUrlConfig; +import com.yas.order.viewmodel.payment.CheckoutPaymentVm; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class PaymentService extends AbstractCircuitBreakFallbackHandler { + + private final RestClient restClient; + + private final ServiceUrlConfig serviceUrlConfig; + + @Retry(name = "restApi") + @CircuitBreaker(name = "restCircuitBreaker", fallbackMethod = "handleLongFallback") + public Long createPaymentFromEvent(CheckoutPaymentVm checkoutPaymentRequestDto) { + + final URI url = UriComponentsBuilder + .fromHttpUrl(serviceUrlConfig.payment()) + .path("/events/payments") + .buildAndExpand() + .toUri(); + + return restClient.post() + .uri(url) + .body(checkoutPaymentRequestDto) + .retrieve() + .body(Long.class); + } + + private Long handleLongFallback(Throwable throwable) + throws Throwable { + return handleTypedFallback(throwable); + } +} diff --git a/order/src/main/java/com/yas/order/utils/Constants.java b/order/src/main/java/com/yas/order/utils/Constants.java index 7a85f82528..3e2f18f55c 100644 --- a/order/src/main/java/com/yas/order/utils/Constants.java +++ b/order/src/main/java/com/yas/order/utils/Constants.java @@ -9,6 +9,12 @@ private ErrorCode() {} public static final String CHECKOUT_NOT_FOUND = "CHECKOUT_NOT_FOUND"; public static final String SIGN_IN_REQUIRED = "SIGN_IN_REQUIRED"; public static final String FORBIDDEN = "FORBIDDEN"; + public static final String CANNOT_CONVERT_TO_STRING = "CANNOT_CONVERT_TO_STRING"; + public static final String PROCESS_CHECKOUT_FAILED = "PROCESS_CHECKOUT_FAILED"; + public static final String ID_NOT_EXISTED = "ID_NOT_EXISTED"; + public static final String STATUS_NOT_EXISTED = "STATUS_NOT_EXISTED"; + public static final String PROGRESS_NOT_EXISTED = "PROGRESS_NOT_EXISTED"; + public static final String CHECKOUT_ID_NOT_EXISTED = "CHECKOUT_ID_NOT_EXISTED"; } public final class Column { @@ -17,6 +23,7 @@ private Column() {} // common column public static final String ID_COLUMN = "id"; + public static final String STATUS_COLUMN = "status"; public static final String CREATE_ON_COLUMN = "createdOn"; public static final String CREATE_BY_COLUMN = "createdBy"; @@ -33,5 +40,12 @@ private Column() {} public static final String ORDER_ITEM_PRODUCT_ID_COLUMN = "productId"; public static final String ORDER_ITEM_PRODUCT_NAME_COLUMN = "productName"; + // Column name of Checkout table + public static final String CHECKOUT_PROGRESS_COLUMN = "progress"; + public static final String CHECKOUT_ATTRIBUTES_PAYMENT_ID_FIELD = "payment_id"; + public static final String CHECKOUT_ID_COLUMN = "checkout_id"; + public static final String CHECKOUT_STATUS_COLUMN = "status"; + public static final String CHECKOUT_ATTRIBUTES_PAYMENT_PROVIDER_CHECKOUT_ID_FIELD + = "payment_provider_checkout_id"; } } diff --git a/order/src/main/java/com/yas/order/utils/JsonUtils.java b/order/src/main/java/com/yas/order/utils/JsonUtils.java new file mode 100644 index 0000000000..1143c70339 --- /dev/null +++ b/order/src/main/java/com/yas/order/utils/JsonUtils.java @@ -0,0 +1,66 @@ +package com.yas.order.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.yas.commonlibrary.exception.BadRequestException; +import java.util.Optional; + +public class JsonUtils { + + private JsonUtils() { + throw new UnsupportedOperationException(); + } + + public static String convertObjectToString(ObjectMapper objectMapper, Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new BadRequestException(Constants.ErrorCode.CANNOT_CONVERT_TO_STRING, value); + } + } + + public static ObjectNode getAttributesNode(ObjectMapper objectMapper, String attributes) { + try { + if (attributes == null || attributes.isBlank()) { + return objectMapper.createObjectNode(); + } else { + return (ObjectNode) objectMapper.readTree(attributes); + } + } catch (JsonProcessingException e) { + throw new BadRequestException("Invalid Json: {}", attributes); + } + } + + public static ObjectNode createJsonErrorObject(ObjectMapper objectMapper, String errorCode, String message) { + ObjectNode objectNode = objectMapper.createObjectNode(); + objectNode.put("errorCode", errorCode); + objectNode.put("message", message); + return objectNode; + } + + public static String getJsonValueOrThrow( + JsonObject jsonObject, + String columnName, + String errorCode, + Object... errorParams + ) { + return Optional.ofNullable(jsonObject.get(columnName)) + .filter(jsonElement -> !jsonElement.isJsonNull()) + .map(JsonElement::getAsString) + .orElseThrow(() -> new BadRequestException(errorCode, errorParams)); + } + + public static String getJsonValueOrNull( + JsonObject jsonObject, + String columnName + ) { + JsonElement jsonElement = jsonObject.get(columnName); + if (jsonElement != null && !jsonElement.isJsonNull()) { + return jsonElement.getAsString(); + } + return null; + } +} \ No newline at end of file diff --git a/order/src/main/java/com/yas/order/viewmodel/checkout/CheckoutVm.java b/order/src/main/java/com/yas/order/viewmodel/checkout/CheckoutVm.java index 815200c8b6..8a99b697d2 100644 --- a/order/src/main/java/com/yas/order/viewmodel/checkout/CheckoutVm.java +++ b/order/src/main/java/com/yas/order/viewmodel/checkout/CheckoutVm.java @@ -1,5 +1,6 @@ package com.yas.order.viewmodel.checkout; +import com.yas.order.model.Checkout; import java.math.BigDecimal; import java.util.Set; import lombok.Builder; @@ -14,4 +15,14 @@ public record CheckoutVm( BigDecimal totalDiscountAmount, Set checkoutItemVms ) { + + public static CheckoutVm fromModel(Checkout checkout, Set checkoutItemVms) { + return CheckoutVm.builder() + .id(checkout.getId()) + .email(checkout.getEmail()) + .note(checkout.getNote()) + .couponCode(checkout.getCouponCode()) + .checkoutItemVms(checkoutItemVms) + .build(); + } } \ No newline at end of file diff --git a/order/src/main/java/com/yas/order/viewmodel/orderaddress/OrderAddressPostVm.java b/order/src/main/java/com/yas/order/viewmodel/orderaddress/OrderAddressPostVm.java index 5007f714f4..6510dc451d 100644 --- a/order/src/main/java/com/yas/order/viewmodel/orderaddress/OrderAddressPostVm.java +++ b/order/src/main/java/com/yas/order/viewmodel/orderaddress/OrderAddressPostVm.java @@ -6,6 +6,7 @@ @Builder public record OrderAddressPostVm( + Long id, @NotBlank String contactName, @NotBlank String phone, @NotBlank String addressLine1, diff --git a/order/src/main/java/com/yas/order/viewmodel/payment/CheckoutPaymentVm.java b/order/src/main/java/com/yas/order/viewmodel/payment/CheckoutPaymentVm.java new file mode 100644 index 0000000000..019f7b5770 --- /dev/null +++ b/order/src/main/java/com/yas/order/viewmodel/payment/CheckoutPaymentVm.java @@ -0,0 +1,8 @@ +package com.yas.order.viewmodel.payment; + +import com.yas.order.model.enumeration.PaymentMethod; +import java.math.BigDecimal; + +public record CheckoutPaymentVm(String checkoutId, PaymentMethod paymentMethod, BigDecimal totalAmount) { + +} \ No newline at end of file diff --git a/order/src/main/resources/application.properties b/order/src/main/resources/application.properties index 31e9b86291..ce6651f706 100644 --- a/order/src/main/resources/application.properties +++ b/order/src/main/resources/application.properties @@ -16,6 +16,7 @@ yas.services.customer=http://api.yas.local/customer yas.services.product=http://api.yas.local/product yas.services.tax=http://api.yas.local/tax yas.services.promotion=http://api.yas.local/promotion +yas.services.payment=http://api.yas.local/payment spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.url=jdbc:postgresql://localhost:5432/order @@ -49,4 +50,19 @@ resilience4j.circuitbreaker.instances.rest-circuit-breaker.failure-rate-threshol resilience4j.circuitbreaker.instances.rest-circuit-breaker.minimum-number-of-calls=5 resilience4j.circuitbreaker.instances.rest-circuit-breaker.automatic-transition-from-open-to-half-open-enabled=true resilience4j.circuitbreaker.instances.rest-circuit-breaker.permitted-number-of-calls-in-half-open-state=3 -cors.allowed-origins=* \ No newline at end of file +cors.allowed-origins=* + +cdc.event.checkout.status.topic-name=dbcheckout-status.public.checkout +cdc.event.checkout.status.group-id=checkout-status + +cdc.event.payment.topic-name=dbpayment.public.payment +cdc.event.payment.update.group-id=payment-update + +# Kafka Consumer +spring.kafka.consumer.bootstrap-servers=kafka:9092 +spring.aop.proxy-target-class=true + +# Kafka Producer +spring.kafka.producer.bootstrap-servers=kafka:9092 +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer \ No newline at end of file diff --git a/order/src/main/resources/messages/messages.properties b/order/src/main/resources/messages/messages.properties index f00590fe86..414c0feeca 100644 --- a/order/src/main/resources/messages/messages.properties +++ b/order/src/main/resources/messages/messages.properties @@ -2,4 +2,11 @@ ORDER_NOT_FOUND=Order {} is not found CHECKOUT_NOT_FOUND=Checkout {} is not found SUCCESS_MESSAGE=Success SIGN_IN_REQUIRED=Authentication required -FORBIDDEN=You don't have permission to access this page \ No newline at end of file +FORBIDDEN=You don't have permission to access this page +PAYMENT_METHOD_NOT_EXISTED=Payment method id is not existed in Checkout {} +CANNOT_CONVERT_TO_STRING=Can not convert object to String : {} +PROCESS_CHECKOUT_FAILED=Failed to process checkout event for ID {} +ID_NOT_EXISTED=ID is not existed +CHECKOUT_ID_NOT_EXISTED=Checkout ID is not existed +STATUS_NOT_EXISTED=Status is missing for Checkout ID {} +PROGRESS_NOT_EXISTED=Progress is missing for Checkout ID {} \ No newline at end of file diff --git a/order/src/test/java/com/yas/order/config/IntegrationTestConfiguration.java b/order/src/test/java/com/yas/order/config/IntegrationTestConfiguration.java new file mode 100644 index 0000000000..622eaca43b --- /dev/null +++ b/order/src/test/java/com/yas/order/config/IntegrationTestConfiguration.java @@ -0,0 +1,17 @@ +package com.yas.order.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; + +@TestConfiguration +public class IntegrationTestConfiguration { + + @Bean(destroyMethod = "stop") + @ServiceConnection + public PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:16") + .withReuse(true); + } +} diff --git a/order/src/test/java/com/yas/order/config/KafkaIntegrationTestConfiguration.java b/order/src/test/java/com/yas/order/config/KafkaIntegrationTestConfiguration.java new file mode 100644 index 0000000000..7cf7e5e2a1 --- /dev/null +++ b/order/src/test/java/com/yas/order/config/KafkaIntegrationTestConfiguration.java @@ -0,0 +1,23 @@ +package com.yas.order.config; + +import common.container.ContainerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.KafkaContainer; + +@TestConfiguration +public class KafkaIntegrationTestConfiguration { + + @Value("${kafka.version}") + private String kafkaVersion; + + @Bean + @ServiceConnection + public KafkaContainer kafkaContainer(DynamicPropertyRegistry registry) { + return ContainerFactory.kafkaContainer(registry, kafkaVersion); + } + +} diff --git a/order/src/test/java/com/yas/order/consumer/OrderStatusConsumerTest.java b/order/src/test/java/com/yas/order/consumer/OrderStatusConsumerTest.java new file mode 100644 index 0000000000..9606677370 --- /dev/null +++ b/order/src/test/java/com/yas/order/consumer/OrderStatusConsumerTest.java @@ -0,0 +1,206 @@ +package com.yas.order.consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.yas.commonlibrary.exception.BadRequestException; +import com.yas.commonlibrary.exception.NotFoundException; +import com.yas.order.kafka.consumer.OrderStatusConsumer; +import com.yas.order.model.Checkout; +import com.yas.order.model.enumeration.CheckoutProgress; +import com.yas.order.repository.CheckoutRepository; +import com.yas.order.service.PaymentService; +import java.util.Optional; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class OrderStatusConsumerTest { + + private PaymentService paymentService; + + private CheckoutRepository checkoutRepository; + + private OrderStatusConsumer orderStatusConsumer; + + private final String jsonRecord = "{\"after\": {" + + " \"status\": \"PAYMENT_PROCESSING\"," + + " \"progress\": \"STOCK_LOCKED\"," + + " \"id\": 12345," + + " \"payment_method_id\": \"PAYPAL\"," + + " \"total_amount\": 123}" + + "}"; + + @BeforeEach + void setUp() { + paymentService = Mockito.mock(PaymentService.class); + checkoutRepository = Mockito.mock(CheckoutRepository.class); + ObjectMapper objectMapper = new ObjectMapper(); + Gson gson = new Gson(); + orderStatusConsumer = new OrderStatusConsumer(paymentService, checkoutRepository, objectMapper, gson); + + } + + @Test + void testListen_whenConsumerRecordIsNull_shouldNotThing() { + + orderStatusConsumer.listen(null); + + verify(checkoutRepository, never()).findById(any()); + verify(paymentService, never()).createPaymentFromEvent(any()); + verify(checkoutRepository, never()).save(any()); + } + + @Test + void testListen_whenHaveNoAfter_shouldNotThing() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + when(consumerRecord.value()).thenReturn("{}"); + + orderStatusConsumer.listen(consumerRecord); + + verify(checkoutRepository, never()).save(any()); + verify(checkoutRepository, never()).findById(any()); + verify(paymentService, never()).createPaymentFromEvent(any()); + } + + @Test + void testListen_whenIsNotPaymentProcess_shouldNotThing() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + String invalidDataJson = "{\"after\": {" + + " \"status\": \"CHECKED_OUT\"," + + " \"progress\": \"STOCK_LOCKED\"," + + " \"id\": 12345," + + " \"payment_method_id\": \"PAYPAL\"," + + " \"total_amount\": 123}" + + "}"; + + when(consumerRecord.value()).thenReturn(invalidDataJson); + + orderStatusConsumer.listen(consumerRecord); + + verify(checkoutRepository, never()).findById(any()); + verify(checkoutRepository, never()).save(any()); + verify(paymentService, never()).createPaymentFromEvent(any()); + } + + @Test + void testListen_whenProgressIsNotStockLocked_shouldNotThing() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + String invalidDataJson = "{\"after\": {" + + " \"status\": \"PAYMENT_PROCESSING\"," + + " \"progress\": \"INIT\"," + + " \"id\": 12345," + + " \"payment_method_id\": \"PAYPAL\"," + + " \"total_amount\": 123}" + + "}"; + + when(consumerRecord.value()).thenReturn(invalidDataJson); + + orderStatusConsumer.listen(consumerRecord); + + verify(checkoutRepository, never()).findById(any()); + verify(checkoutRepository, never()).save(any()); + verify(paymentService, never()).createPaymentFromEvent(any()); + } + + @Test + void testListen_whenCheckoutIsNotFound_shouldNotSave() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + when(consumerRecord.value()).thenReturn(jsonRecord); + JsonObject jsonObject = mock(JsonObject.class); + when(jsonObject.has("after")).thenReturn(true); + when(jsonObject.getAsJsonObject("after")).thenReturn(mock(JsonObject.class)); + + when(checkoutRepository.findById(anyString())).thenReturn(Optional.empty()); + + when(paymentService.createPaymentFromEvent(any())).thenReturn(1L); + + NotFoundException notFoundException + = Assertions.assertThrows(NotFoundException.class, () -> orderStatusConsumer.listen(consumerRecord)); + + assertThat(notFoundException.getMessage()).isEqualTo("Checkout 12345 is not found"); + verify(checkoutRepository, atLeastOnce()).findById(any()); + verify(checkoutRepository, never()).save(any()); + verify(paymentService, never()).createPaymentFromEvent(any()); + } + + @Test + void testListen_whenCreatePaymentSuccess_shouldProcessCheckoutEvent() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + when(consumerRecord.value()).thenReturn(jsonRecord); + JsonObject jsonObject = mock(JsonObject.class); + when(jsonObject.has("after")).thenReturn(true); + when(jsonObject.getAsJsonObject("after")).thenReturn(mock(JsonObject.class)); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Checkout.class); + + when(checkoutRepository.findById(anyString())).thenReturn(Optional.of(new Checkout())); + + when(paymentService.createPaymentFromEvent(any())).thenReturn(2L); + + orderStatusConsumer.listen(consumerRecord); + + verify(checkoutRepository, atLeastOnce()).findById(any()); + verify(paymentService, atLeastOnce()).createPaymentFromEvent(any()); + verify(checkoutRepository, atLeastOnce()).save(argumentCaptor.capture()); + + Checkout actual = argumentCaptor.getValue(); + assertThat(actual.getProgress()).isEqualTo(CheckoutProgress.PAYMENT_CREATED); + assertThat(actual.getAttributes()).isEqualTo("{\"payment_id\":2}"); + } + + @Test + void testListen_whenCreatePaymentFailure_shouldThrowException() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + when(consumerRecord.value()).thenReturn(jsonRecord); + JsonObject jsonObject = mock(JsonObject.class); + when(jsonObject.has("after")).thenReturn(true); + when(jsonObject.getAsJsonObject("after")).thenReturn(mock(JsonObject.class)); + + Checkout checkout = new Checkout(); + checkout.setId("12345"); + when(checkoutRepository.findById(anyString())).thenReturn(Optional.of(checkout)); + + BadRequestException badRequestException = new BadRequestException("test exception"); + when(paymentService.createPaymentFromEvent(any())).thenThrow(badRequestException); + + BadRequestException badRequest + = Assertions.assertThrows(BadRequestException.class, () -> orderStatusConsumer.listen(consumerRecord)); + assertThat(badRequest.getMessage()).isEqualTo("Failed to process checkout event for ID 12345"); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Checkout.class); + verify(checkoutRepository, atLeastOnce()).findById(any()); + verify(checkoutRepository, atLeastOnce()).save(argumentCaptor.capture()); + verify(paymentService, atLeastOnce()).createPaymentFromEvent(any()); + + Checkout actual = argumentCaptor.getValue(); + assertThat(actual.getProgress()).isEqualTo(CheckoutProgress.PAYMENT_CREATED_FAILED); + assertThat(actual.getLastError()) + .isEqualTo("{\"errorCode\":\"PAYMENT_CREATED_FAILED\",\"message\":\"test exception\"}"); + + } +} diff --git a/order/src/test/java/com/yas/order/consumer/PaymentUpdateConsumerTest.java b/order/src/test/java/com/yas/order/consumer/PaymentUpdateConsumerTest.java new file mode 100644 index 0000000000..c4783b0ab9 --- /dev/null +++ b/order/src/test/java/com/yas/order/consumer/PaymentUpdateConsumerTest.java @@ -0,0 +1,136 @@ +package com.yas.order.consumer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.yas.order.kafka.consumer.PaymentUpdateConsumer; +import com.yas.order.model.Checkout; +import com.yas.order.model.enumeration.CheckoutProgress; +import com.yas.order.model.enumeration.CheckoutState; +import com.yas.order.service.CheckoutService; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class PaymentUpdateConsumerTest { + + private CheckoutService checkoutService; + + private PaymentUpdateConsumer paymentUpdateConsumer; + + private final String jsonRecord = "{\"op\":\"u\"," + + " \"before\":{\"payment_provider_checkout_id\":\"OLD\"}," + + " \"after\":{\"payment_provider_checkout_id\":\"NEW\"," + + " \"id\":\"1\"," + + " \"checkout_id\":\"12345\"}}"; + + @BeforeEach + void setUp() { + checkoutService = mock(CheckoutService.class); + ObjectMapper objectMapper = new ObjectMapper(); + Gson gson = new Gson(); + paymentUpdateConsumer = new PaymentUpdateConsumer(checkoutService, objectMapper, gson); + } + + @Test + void testListen_whenConsumerRecordIsNull_shouldLogInfoAndDoNothing() { + + paymentUpdateConsumer.listen(null); + + verify(checkoutService, never()).findCheckoutById(anyString()); + verify(checkoutService, never()).updateCheckout(any()); + } + + @Test + void testListen_whenValueDoesNotContainOpField_shouldNotUpdateCheckout() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + + when(consumerRecord.value()).thenReturn("{\"invalidKey\":\"value\"}"); + + paymentUpdateConsumer.listen(consumerRecord); + + verify(checkoutService, never()).findCheckoutById(anyString()); + verify(checkoutService, never()).updateCheckout(any()); + } + + @Test + void testListen_whenEventDoesNotContainBeforeField_shouldNotUpdateCheckout() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"u\"," + + " \"after\":{\"payment_provider_checkout_id\":\"OLD\"}}"); + + paymentUpdateConsumer.listen(consumerRecord); + + verify(checkoutService, never()).findCheckoutById(anyString()); + verify(checkoutService, never()).updateCheckout(any()); + } + + @Test + void testListen_whenEventDoesNotContainAfterField_shouldNotUpdateCheckout() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"u\"," + + " \"before\":{\"payment_provider_checkout_id\":\"OLD\"}}"); + + paymentUpdateConsumer.listen(consumerRecord); + + verify(checkoutService, never()).updateCheckout(any()); + verify(checkoutService, never()).findCheckoutById(anyString()); + } + + @Test + void testListen_whenEventAfterPaypalOrderIdIsNull_shouldNotUpdateCheckout() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"u\"," + + " \"before\":{\"payment_provider_checkout_id\":\"OLD\"}," + + " \"after\":{" + + " \"id\":\"1\"," + + " \"checkout_id\":\"12345\"}}"); + + paymentUpdateConsumer.listen(consumerRecord); + + verify(checkoutService, never()).updateCheckout(any()); + } + + @Test + void testListen_whenEventAfterPaypalOrderIdEqualsBefore_shouldNotUpdateCheckout() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"u\"," + + " \"before\":{\"payment_provider_checkout_id\":\"OLD\"}," + + " \"after\":{\"payment_provider_checkout_id\":\"OLD\"," + + " \"id\":\"1\"," + + " \"checkout_id\":\"12345\"}}"); + + paymentUpdateConsumer.listen(consumerRecord); + + verify(checkoutService, never()).updateCheckout(any()); + verify(checkoutService, never()).findCheckoutById(anyString()); + } + + @Test + void testListen_whenEventIsUpdateAndAttributesChanged_shouldUpdateCheckout() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn(jsonRecord); + + Checkout mockCheckout = new Checkout(); + mockCheckout.setId("12345"); + when(checkoutService.findCheckoutById("12345")).thenReturn(mockCheckout); + + paymentUpdateConsumer.listen(consumerRecord); + + ArgumentCaptor checkoutCaptor = ArgumentCaptor.forClass(Checkout.class); + verify(checkoutService).updateCheckout(checkoutCaptor.capture()); + + Checkout updatedCheckout = checkoutCaptor.getValue(); + assertEquals(CheckoutState.PAYMENT_PROCESSING, updatedCheckout.getCheckoutState()); + assertEquals(CheckoutProgress.PAYMENT_CREATED, updatedCheckout.getProgress()); + } +} diff --git a/order/src/test/java/com/yas/order/controller/CheckoutControllerTest.java b/order/src/test/java/com/yas/order/controller/CheckoutControllerTest.java index 7e6e38a702..19fc7ce794 100644 --- a/order/src/test/java/com/yas/order/controller/CheckoutControllerTest.java +++ b/order/src/test/java/com/yas/order/controller/CheckoutControllerTest.java @@ -1,7 +1,9 @@ package com.yas.order.controller; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -13,17 +15,14 @@ import com.yas.order.service.CheckoutService; import com.yas.order.viewmodel.checkout.CheckoutItemPostVm; import com.yas.order.viewmodel.checkout.CheckoutItemVm; +import com.yas.order.viewmodel.checkout.CheckoutPaymentMethodPutVm; import com.yas.order.viewmodel.checkout.CheckoutPostVm; import com.yas.order.viewmodel.checkout.CheckoutStatusPutVm; import com.yas.order.viewmodel.checkout.CheckoutVm; - -import com.yas.order.viewmodel.checkout.*; - import java.math.BigDecimal; import java.util.HashSet; import java.util.List; import java.util.Set; - import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -96,18 +95,31 @@ void testUpdateCheckoutStatus_whenRequestIsValid_thenReturnLong() throws Excepti } @Test - void testGetOrderWithItemsById_whenRequestIsValid_thenReturnCheckoutVm() throws Exception { + void testGetPendingCheckoutDetailsById_whenRequestIsValid_thenReturnCheckoutVm() throws Exception { String id = "123"; CheckoutVm response = getCheckoutVm(); when(checkoutService.getCheckoutPendingStateWithItemsById(id)).thenReturn(response); - mockMvc.perform(get("/storefront/checkouts/{id}", id) + mockMvc.perform(get("/storefront/checkouts/pending/{id}", id) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.content().json(objectWriter.writeValueAsString(response))); } + @Test + void testGetCheckoutById_whenRequestIsValid_thenReturnCheckoutVm() throws Exception { + + String id = "123"; + CheckoutVm response = getCheckoutVm(); + when(checkoutService.findCheckoutWithItemsById(id)).thenReturn(response); + + mockMvc.perform(get("/storefront/checkouts/{id}", id) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(objectWriter.writeValueAsString(response))); + } + @Test void testUpdatePaymentMethod_whenRequestIsValid_thenReturnNoContent() throws Exception { String id = "123"; diff --git a/order/src/test/java/com/yas/order/controller/OrderControllerTest.java b/order/src/test/java/com/yas/order/controller/OrderControllerTest.java index 1939c4b0a1..ad92143799 100644 --- a/order/src/test/java/com/yas/order/controller/OrderControllerTest.java +++ b/order/src/test/java/com/yas/order/controller/OrderControllerTest.java @@ -332,6 +332,7 @@ private Set getOrderItemVms() { private OrderPostVm getOrderPostVm() { OrderAddressPostVm shippingAddress = new OrderAddressPostVm( + 1L, "John Doe", "+123456789", "123 Main St", @@ -347,6 +348,7 @@ private OrderPostVm getOrderPostVm() { ); OrderAddressPostVm billingAddress = new OrderAddressPostVm( + 1L, "Jane Smith", "+1987654321", "789 Elm Street", diff --git a/order/src/test/java/com/yas/order/service/CheckoutServiceTest.java b/order/src/test/java/com/yas/order/service/CheckoutServiceTest.java index cfa16d5f78..ac2b4e8610 100644 --- a/order/src/test/java/com/yas/order/service/CheckoutServiceTest.java +++ b/order/src/test/java/com/yas/order/service/CheckoutServiceTest.java @@ -7,9 +7,12 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.yas.commonlibrary.exception.BadRequestException; import com.yas.commonlibrary.exception.Forbidden; import com.yas.commonlibrary.exception.NotFoundException; import com.yas.order.mapper.CheckoutMapperImpl; @@ -20,11 +23,12 @@ import com.yas.order.repository.CheckoutRepository; import com.yas.order.viewmodel.checkout.CheckoutPaymentMethodPutVm; import com.yas.order.viewmodel.checkout.CheckoutPostVm; +import com.yas.order.viewmodel.checkout.CheckoutVm; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; import java.util.UUID; import org.instancio.Instancio; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -181,7 +185,7 @@ void testUpdateCheckoutPaymentMethod_whenCheckoutExists_thenUpdatePaymentMethod( Checkout checkout = new Checkout(); checkout.setId(id); - CheckoutPaymentMethodPutVm request = new CheckoutPaymentMethodPutVm("new-payment-method-id"); + CheckoutPaymentMethodPutVm request = new CheckoutPaymentMethodPutVm("BANKING"); when(checkoutRepository.findById(id)).thenReturn(Optional.of(checkout)); @@ -190,7 +194,7 @@ void testUpdateCheckoutPaymentMethod_whenCheckoutExists_thenUpdatePaymentMethod( // Assert verify(checkoutRepository).save(checkout); - assertThat(checkout.getPaymentMethodId()).isEqualTo(request.paymentMethodId()); + assertThat(checkout.getPaymentMethodId().name()).isEqualTo(request.paymentMethodId()); } @Test @@ -220,7 +224,83 @@ void testUpdateCheckoutPaymentMethod_whenPaymentMethodIdIsNull_thenDoNotUpdate() checkoutService.updateCheckoutPaymentMethod(id, request); // Assert - verify(checkoutRepository).save(checkout); + verify(checkoutRepository, never()).save(checkout); assertThat(checkout.getPaymentMethodId()).isNull(); } + + @Test + void testFindCheckoutById_whenNotFound_throwNotFoundException() { + String id = "123"; + when(checkoutRepository.findById(id)) + .thenReturn(Optional.empty()); + NotFoundException notFound = assertThrows(NotFoundException.class, + () -> checkoutService.findCheckoutById(id)); + assertThat(notFound.getMessage()).isEqualTo("Checkout 123 is not found"); + } + + @Test + void testFindCheckoutWithItemsById_whenNormalCase_returnCheckoutVm() { + String id = "123"; + Checkout checkout = new Checkout(); + checkout.setId(id); + when(checkoutRepository.findById(id)) + .thenReturn(Optional.of(checkout)); + + List checkoutItemVms = getCheckoutItems(); + when(checkoutItemRepository.findAllByCheckoutId(id)) + .thenReturn(checkoutItemVms); + CheckoutVm checkoutVm = checkoutService.findCheckoutWithItemsById(id); + assertThat(checkoutVm.id()).isEqualTo(id); + assertThat(checkoutVm.checkoutItemVms()).hasSize(2); + } + + @Test + void testUpdateCheckout_whenNormalCase_thenUpdateCheckout() { + String id = "123"; + Checkout checkout = new Checkout(); + checkout.setId(id); + checkoutService.updateCheckout(checkout); + verify(checkoutRepository, times(1)).save(checkout); + } + + @Test + void testUpdateCheckout_whenIdIsNull_throwBadRequestException() { + Checkout checkout = new Checkout(); + BadRequestException badRequestException = assertThrows(BadRequestException.class, + () -> checkoutService.updateCheckout(checkout)); + assertThat(badRequestException.getMessage()).isEqualTo("ID is not existed"); + } + + private List getCheckoutItems() { + return List.of( + CheckoutItem.builder() + .id(1L) + .productId(101L) + .checkoutId("checkout123") + .productName("Product A") + .quantity(2) + .productPrice(BigDecimal.valueOf(50.0)) + .note("Note A") + .discountAmount(BigDecimal.valueOf(5.0)) + .taxAmount(BigDecimal.valueOf(1.0)) + .taxPercent(BigDecimal.valueOf(0.02)) + .shipmentTax(BigDecimal.valueOf(0.5)) + .shipmentFee(BigDecimal.valueOf(3.0)) + .build(), + CheckoutItem.builder() + .id(2L) + .productId(102L) + .checkoutId("checkout124") + .productName("Product B") + .quantity(1) + .productPrice(BigDecimal.valueOf(30.0)) + .note("Note B") + .discountAmount(BigDecimal.valueOf(3.0)) + .taxAmount(BigDecimal.valueOf(0.6)) + .taxPercent(BigDecimal.valueOf(0.02)) + .shipmentTax(BigDecimal.valueOf(0.3)) + .shipmentFee(BigDecimal.valueOf(2.0)) + .build() + ); + } } \ No newline at end of file diff --git a/order/src/test/java/com/yas/order/service/PaymentServiceTest.java b/order/src/test/java/com/yas/order/service/PaymentServiceTest.java new file mode 100644 index 0000000000..a04566d581 --- /dev/null +++ b/order/src/test/java/com/yas/order/service/PaymentServiceTest.java @@ -0,0 +1,74 @@ +package com.yas.order.service; + +import static com.yas.order.utils.SecurityContextUtils.setUpSecurityContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.yas.order.config.ServiceUrlConfig; +import com.yas.order.model.enumeration.PaymentMethod; +import com.yas.order.viewmodel.payment.CheckoutPaymentVm; +import java.math.BigDecimal; +import java.net.URI; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +class PaymentServiceTest { + + private RestClient restClient; + + private ServiceUrlConfig serviceUrlConfig; + + private PaymentService paymentService; + + private RestClient.ResponseSpec responseSpec; + + private static final String PAYMENT_URL = "http://api.yas.local/payment"; + + @BeforeEach + void setUp() { + restClient = mock(RestClient.class); + serviceUrlConfig = mock(ServiceUrlConfig.class); + paymentService = new PaymentService(restClient, serviceUrlConfig); + responseSpec = Mockito.mock(RestClient.ResponseSpec.class); + setUpSecurityContext("test"); + when(serviceUrlConfig.payment()).thenReturn(PAYMENT_URL); + } + + @Test + void testCreatePaymentFromEvent_ifNormalCase_returnLong() { + + final URI url = UriComponentsBuilder + .fromHttpUrl(serviceUrlConfig.payment()) + .path("/events/payments") + .buildAndExpand() + .toUri(); + + RestClient.RequestBodyUriSpec requestBodyUriSpec = mock(RestClient.RequestBodyUriSpec.class); + when(restClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(url)).thenReturn(requestBodyUriSpec); + + CheckoutPaymentVm checkoutPaymentRequestDto = new CheckoutPaymentVm( + "123", + PaymentMethod.PAYPAL, + new BigDecimal(12) + ); + + when(requestBodyUriSpec.headers(any())).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(checkoutPaymentRequestDto)).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(Long.class)).thenReturn(1L); + + Long result = paymentService.createPaymentFromEvent(checkoutPaymentRequestDto); + + assertNotNull(result); + assertThat(result).isEqualTo(1L); + + } + +} \ No newline at end of file diff --git a/order/src/test/resources/application.properties b/order/src/test/resources/application.properties index 8587b5409a..5605601269 100644 --- a/order/src/test/resources/application.properties +++ b/order/src/test/resources/application.properties @@ -2,19 +2,27 @@ server.servlet.context-path=/v1 server.port=8085 -# Setting Spring profile -spring.profiles.active=test - -spring.datasource.url=jdbc:h2:mem:testdb;NON_KEYWORDS=VALUE -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect - spring.jpa.hibernate.ddl-auto=update spring.liquibase.enabled=false spring.security.oauth2.resourceserver.jwt.issuer-uri=test springdoc.oauthflow.authorization-url=test springdoc.oauthflow.token-url=test -cors.allowed-origins=* \ No newline at end of file +cors.allowed-origins=* + +cdc.event.checkout.status.topic-name=dbcheckout-status.public.checkout +cdc.event.checkout.status.group-id=checkout-status + +cdc.event.payment.topic-name=dbpayment.public.payment +cdc.event.payment.update.group-id=payment-update + +kafka.version=7.0.9 + +spring.kafka.consumer.bootstrap-servers=kafka:9092 +spring.aop.proxy-target-class=true + +spring.kafka.producer.bootstrap-servers=kafka:9092 +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer + +spring.kafka.bootstrap-servers=localhost:9092 \ No newline at end of file diff --git a/payment-paypal/src/main/java/com/yas/payment/paypal/service/PaypalService.java b/payment-paypal/src/main/java/com/yas/payment/paypal/service/PaypalService.java index 6684077b74..5116c37453 100644 --- a/payment-paypal/src/main/java/com/yas/payment/paypal/service/PaypalService.java +++ b/payment-paypal/src/main/java/com/yas/payment/paypal/service/PaypalService.java @@ -2,22 +2,28 @@ import com.paypal.core.PayPalHttpClient; import com.paypal.http.HttpResponse; -import com.paypal.orders.*; +import com.paypal.orders.AmountWithBreakdown; +import com.paypal.orders.ApplicationContext; +import com.paypal.orders.Capture; +import com.paypal.orders.Order; +import com.paypal.orders.OrderRequest; +import com.paypal.orders.OrdersCaptureRequest; +import com.paypal.orders.OrdersCreateRequest; +import com.paypal.orders.PurchaseUnitRequest; import com.yas.payment.paypal.model.CheckoutIdHelper; import com.yas.payment.paypal.utils.Constants; import com.yas.payment.paypal.viewmodel.PaypalCapturePaymentRequest; import com.yas.payment.paypal.viewmodel.PaypalCapturePaymentResponse; import com.yas.payment.paypal.viewmodel.PaypalCreatePaymentRequest; import com.yas.payment.paypal.viewmodel.PaypalCreatePaymentResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - import java.io.IOException; import java.math.BigDecimal; import java.util.List; import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; @Service @Slf4j @@ -31,7 +37,8 @@ public class PaypalService { private String cancelUrl; public PaypalCreatePaymentResponse createPayment(PaypalCreatePaymentRequest createPaymentRequest) { - PayPalHttpClient payPalHttpClient = payPalHttpClientInitializer.createPaypalClient(createPaymentRequest.paymentSettings()); + PayPalHttpClient payPalHttpClient + = payPalHttpClientInitializer.createPaypalClient(createPaymentRequest.paymentSettings()); OrderRequest orderRequest = new OrderRequest(); orderRequest.checkoutPaymentIntent("CAPTURE"); @@ -45,7 +52,8 @@ public PaypalCreatePaymentResponse createPayment(PaypalCreatePaymentRequest crea .value(totalPrice.toString()); PurchaseUnitRequest purchaseUnitRequest = new PurchaseUnitRequest().amountWithBreakdown(amountWithBreakdown); orderRequest.purchaseUnits(List.of(purchaseUnitRequest)); - String paymentMethodReturnUrl = String.format("%s?paymentMethod=%s", returnUrl, createPaymentRequest.paymentMethod()); + String paymentMethodReturnUrl + = String.format("%s?paymentMethod=%s", returnUrl, createPaymentRequest.paymentMethod()); ApplicationContext applicationContext = new ApplicationContext() .returnUrl(paymentMethodReturnUrl) .cancelUrl(cancelUrl) @@ -75,7 +83,8 @@ public PaypalCreatePaymentResponse createPayment(PaypalCreatePaymentRequest crea } public PaypalCapturePaymentResponse capturePayment(PaypalCapturePaymentRequest capturePaymentRequest) { - PayPalHttpClient payPalHttpClient = payPalHttpClientInitializer.createPaypalClient(capturePaymentRequest.paymentSettings()); + PayPalHttpClient payPalHttpClient + = payPalHttpClientInitializer.createPaypalClient(capturePaymentRequest.paymentSettings()); OrdersCaptureRequest ordersCaptureRequest = new OrdersCaptureRequest(capturePaymentRequest.token()); try { HttpResponse httpResponse = payPalHttpClient.execute(ordersCaptureRequest); @@ -87,7 +96,7 @@ public PaypalCapturePaymentResponse capturePayment(PaypalCapturePaymentRequest c BigDecimal paymentFee = new BigDecimal(paypalFee); BigDecimal amount = new BigDecimal(capture.amount().value()); - PaypalCapturePaymentResponse capturedPayment = PaypalCapturePaymentResponse.builder() + return PaypalCapturePaymentResponse.builder() .paymentFee(paymentFee) .gatewayTransactionId(order.id()) .amount(amount) @@ -95,7 +104,6 @@ public PaypalCapturePaymentResponse capturePayment(PaypalCapturePaymentRequest c .paymentMethod("PAYPAL") .checkoutId(CheckoutIdHelper.getCheckoutId()) .build(); - return capturedPayment; } } catch (IOException e) { log.error(e.getMessage()); diff --git a/payment-paypal/src/main/java/com/yas/payment/paypal/viewmodel/PaypalCreatePaymentRequest.java b/payment-paypal/src/main/java/com/yas/payment/paypal/viewmodel/PaypalCreatePaymentRequest.java index 326ce70253..5421ea3558 100644 --- a/payment-paypal/src/main/java/com/yas/payment/paypal/viewmodel/PaypalCreatePaymentRequest.java +++ b/payment-paypal/src/main/java/com/yas/payment/paypal/viewmodel/PaypalCreatePaymentRequest.java @@ -1,9 +1,10 @@ package com.yas.payment.paypal.viewmodel; -import lombok.Builder; - import java.math.BigDecimal; +import lombok.Builder; @Builder -public record PaypalCreatePaymentRequest(BigDecimal totalPrice, String checkoutId, String paymentMethod, String paymentSettings) { +public record PaypalCreatePaymentRequest( + BigDecimal totalPrice, String checkoutId, String paymentMethod, String paymentSettings +) { } \ No newline at end of file diff --git a/payment/pom.xml b/payment/pom.xml index b0b81a3714..03f8167280 100644 --- a/payment/pom.xml +++ b/payment/pom.xml @@ -25,6 +25,14 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server + + org.springframework.kafka + spring-kafka + + + com.google.code.gson + gson + org.springframework.boot spring-boot-starter-validation diff --git a/payment/src/main/java/com/yas/payment/config/SecurityConfig.java b/payment/src/main/java/com/yas/payment/config/SecurityConfig.java index e0ee4bb6f8..cd140341d9 100644 --- a/payment/src/main/java/com/yas/payment/config/SecurityConfig.java +++ b/payment/src/main/java/com/yas/payment/config/SecurityConfig.java @@ -28,6 +28,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/storefront/**").permitAll() .requestMatchers("/backoffice/**").hasRole("ADMIN") .requestMatchers("/payment-providers/**").permitAll() + .requestMatchers("/capture-payment").permitAll() + .requestMatchers("/events/payments").permitAll() .requestMatchers("/actuator/**").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) diff --git a/payment/src/main/java/com/yas/payment/consumer/PaymentCreateConsumer.java b/payment/src/main/java/com/yas/payment/consumer/PaymentCreateConsumer.java new file mode 100644 index 0000000000..0045a95ae8 --- /dev/null +++ b/payment/src/main/java/com/yas/payment/consumer/PaymentCreateConsumer.java @@ -0,0 +1,101 @@ +package com.yas.payment.consumer; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.yas.commonlibrary.exception.BadRequestException; +import com.yas.payment.model.Payment; +import com.yas.payment.model.enumeration.PaymentMethod; +import com.yas.payment.model.enumeration.PaymentStatus; +import com.yas.payment.service.PaymentService; +import com.yas.payment.utils.Constants; +import com.yas.payment.viewmodel.InitPaymentRequestVm; +import com.yas.payment.viewmodel.InitPaymentResponseVm; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PaymentCreateConsumer { + + private static final Logger LOGGER = LoggerFactory.getLogger(PaymentCreateConsumer.class); + private final PaymentService paymentService; + private final Gson gson; + + @KafkaListener( + topics = "${cdc.event.payment.topic-name}", + groupId = "${cdc.event.payment.create.group-id}" + ) + @RetryableTopic + public void listen(ConsumerRecord consumerRecord) { + + if (Objects.isNull(consumerRecord)) { + LOGGER.info("Consumer Record is null"); + return; + } + JsonObject valueObject = gson.fromJson((String) consumerRecord.value(), JsonObject.class); + processCheckoutEvent(valueObject); + + } + + private void processCheckoutEvent(JsonObject valueObject) { + Optional.ofNullable(valueObject) + .filter( + value -> value.has("op") && "c".equals(value.get("op").getAsString()) + ) + .filter(value -> value.has("after")) + .map(value -> value.getAsJsonObject("after")) + .ifPresent(this::handleAfterJsonForCreatingOrder); + } + + private void handleAfterJsonForCreatingOrder(JsonObject after) { + + Long id = Optional.ofNullable(after.get(Constants.Column.ID_COLUMN)) + .filter(jsonElement -> !jsonElement.isJsonNull()) + .map(JsonElement::getAsLong) + .orElseThrow(() -> new BadRequestException(Constants.ErrorCode.ID_NOT_EXISTED)); + + LOGGER.info("Handle after json for creating order Payment ID {}", id); + + Payment payment = paymentService.findPaymentById(id); + + if (PaymentMethod.COD.equals(payment.getPaymentMethod())) { + LOGGER.warn(Constants.Message.PAYMENT_METHOD_COD, payment.getId()); + return; + } + + if (PaymentMethod.PAYPAL.equals(payment.getPaymentMethod())) { + LOGGER.info(Constants.Message.PAYMENT_METHOD_PAYPAL, payment.getId()); + createOrderOnPaypal(payment); + } else { + LOGGER.warn("Currently only support payment method is Paypal"); + } + } + + private void createOrderOnPaypal(Payment payment) { + + InitPaymentRequestVm initPaymentRequestVm = InitPaymentRequestVm.builder() + .paymentMethod(payment.getPaymentMethod().name()) + .totalPrice(payment.getAmount()) + .checkoutId(payment.getCheckoutId()).build(); + InitPaymentResponseVm initPaymentResponseVm = paymentService.initPayment(initPaymentRequestVm); + + if ("success".equals(initPaymentResponseVm.status())) { + + payment.setPaymentStatus(PaymentStatus.PROCESSING); + payment.setPaymentProviderCheckoutId(initPaymentResponseVm.paymentId()); + + paymentService.updatePayment(payment); + } else { + LOGGER.warn(Constants.ErrorCode.ORDER_CREATION_FAILED, payment.getId()); + } + } + +} \ No newline at end of file diff --git a/payment/src/main/java/com/yas/payment/controller/PaymentController.java b/payment/src/main/java/com/yas/payment/controller/PaymentController.java index 02a845e858..b4a7ca4743 100644 --- a/payment/src/main/java/com/yas/payment/controller/PaymentController.java +++ b/payment/src/main/java/com/yas/payment/controller/PaymentController.java @@ -1,14 +1,19 @@ package com.yas.payment.controller; +import com.yas.payment.model.Payment; import com.yas.payment.service.PaymentService; import com.yas.payment.viewmodel.CapturePaymentRequestVm; import com.yas.payment.viewmodel.CapturePaymentResponseVm; +import com.yas.payment.viewmodel.CheckoutPaymentVm; import com.yas.payment.viewmodel.InitPaymentRequestVm; import com.yas.payment.viewmodel.InitPaymentResponseVm; +import com.yas.payment.viewmodel.PaymentVm; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +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.RestController; @@ -33,4 +38,19 @@ public CapturePaymentResponseVm capturePayment(@Valid @RequestBody CapturePaymen public ResponseEntity cancelPayment() { return ResponseEntity.ok("Payment cancelled"); } + + @PostMapping("/events/payments") + public ResponseEntity createPaymentFromEvent( + @Valid @RequestBody CheckoutPaymentVm checkoutPaymentRequestDto + ) { + + Long paymentId = paymentService.createPaymentFromEvent(checkoutPaymentRequestDto); + return new ResponseEntity<>(paymentId, HttpStatus.CREATED); + } + + @GetMapping("/storefront/payments/{id}") + public ResponseEntity getPaymentById(@PathVariable Long id) { + Payment payment = paymentService.findPaymentById(id); + return ResponseEntity.ok(PaymentVm.fromModel(payment)); + } } diff --git a/payment/src/main/java/com/yas/payment/model/CapturedPayment.java b/payment/src/main/java/com/yas/payment/model/CapturedPayment.java index ce67b48099..f3c69e97ac 100644 --- a/payment/src/main/java/com/yas/payment/model/CapturedPayment.java +++ b/payment/src/main/java/com/yas/payment/model/CapturedPayment.java @@ -2,13 +2,12 @@ import com.yas.payment.model.enumeration.PaymentMethod; import com.yas.payment.model.enumeration.PaymentStatus; +import java.math.BigDecimal; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Setter; -import java.math.BigDecimal; - @AllArgsConstructor @Builder @Setter diff --git a/payment/src/main/java/com/yas/payment/model/Payment.java b/payment/src/main/java/com/yas/payment/model/Payment.java index 5c2b93d583..0a9ca2d80e 100644 --- a/payment/src/main/java/com/yas/payment/model/Payment.java +++ b/payment/src/main/java/com/yas/payment/model/Payment.java @@ -47,7 +47,6 @@ public class Payment extends AbstractAuditEntity { private String failureMessage; - @SuppressWarnings("unused") private String paymentProviderCheckoutId; } diff --git a/payment/src/main/java/com/yas/payment/model/enumeration/PaymentStatus.java b/payment/src/main/java/com/yas/payment/model/enumeration/PaymentStatus.java index ac8c623274..d3b3280ef0 100644 --- a/payment/src/main/java/com/yas/payment/model/enumeration/PaymentStatus.java +++ b/payment/src/main/java/com/yas/payment/model/enumeration/PaymentStatus.java @@ -1,6 +1,8 @@ package com.yas.payment.model.enumeration; public enum PaymentStatus { + NEW, + PROCESSING, PENDING, COMPLETED, CANCELLED diff --git a/payment/src/main/java/com/yas/payment/service/OrderService.java b/payment/src/main/java/com/yas/payment/service/OrderService.java index b852e8433f..283cb9010b 100644 --- a/payment/src/main/java/com/yas/payment/service/OrderService.java +++ b/payment/src/main/java/com/yas/payment/service/OrderService.java @@ -6,6 +6,7 @@ import com.yas.payment.viewmodel.PaymentOrderStatusVm; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.retry.annotation.Retry; +import java.net.URI; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,8 +15,6 @@ import org.springframework.web.client.RestClient; import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; - @Service @Slf4j @RequiredArgsConstructor diff --git a/payment/src/main/java/com/yas/payment/service/PaymentService.java b/payment/src/main/java/com/yas/payment/service/PaymentService.java index 407bbfbee4..e7f519aa2a 100644 --- a/payment/src/main/java/com/yas/payment/service/PaymentService.java +++ b/payment/src/main/java/com/yas/payment/service/PaymentService.java @@ -1,30 +1,45 @@ package com.yas.payment.service; +import com.yas.commonlibrary.exception.BadRequestException; +import com.yas.commonlibrary.exception.NotFoundException; import com.yas.payment.model.CapturedPayment; import com.yas.payment.model.InitiatedPayment; import com.yas.payment.model.Payment; +import com.yas.payment.model.enumeration.PaymentMethod; +import com.yas.payment.model.enumeration.PaymentStatus; import com.yas.payment.repository.PaymentRepository; import com.yas.payment.service.provider.handler.PaymentHandler; -import com.yas.payment.viewmodel.*; +import com.yas.payment.utils.Constants; +import com.yas.payment.viewmodel.CapturePaymentRequestVm; +import com.yas.payment.viewmodel.CapturePaymentResponseVm; +import com.yas.payment.viewmodel.CheckoutPaymentVm; +import com.yas.payment.viewmodel.InitPaymentRequestVm; +import com.yas.payment.viewmodel.InitPaymentResponseVm; +import com.yas.payment.viewmodel.PaymentOrderStatusVm; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor public class PaymentService { + + private static final Logger LOGGER = LoggerFactory.getLogger(PaymentService.class); + private final PaymentRepository paymentRepository; + private final OrderService orderService; + private final Map providers = new HashMap<>(); - @Autowired private final List paymentHandlers; @PostConstruct @@ -52,9 +67,9 @@ public InitPaymentResponseVm initPayment(InitPaymentRequestVm initPaymentRequest .build(); } - public CapturePaymentResponseVm capturePayment(CapturePaymentRequestVm capturePaymentRequestVM) { - PaymentHandler paymentHandler = getPaymentHandler(capturePaymentRequestVM.paymentMethod()); - CapturedPayment capturedPayment = paymentHandler.capturePayment(capturePaymentRequestVM); + public CapturePaymentResponseVm capturePayment(CapturePaymentRequestVm capturePaymentRequestVm) { + PaymentHandler paymentHandler = getPaymentHandler(capturePaymentRequestVm.paymentMethod()); + CapturedPayment capturedPayment = paymentHandler.capturePayment(capturePaymentRequestVm); Long orderId = orderService.updateCheckoutStatus(capturedPayment); capturedPayment.setOrderId(orderId); Payment payment = createPayment(capturedPayment); @@ -90,4 +105,40 @@ private Payment createPayment(CapturedPayment capturedPayment) { .build(); return paymentRepository.save(payment); } + + public Long createPaymentFromEvent(CheckoutPaymentVm checkoutPaymentVm) { + + Payment payment = Payment.builder() + .checkoutId(checkoutPaymentVm.checkoutId()) + .paymentStatus( + PaymentMethod.COD.equals(checkoutPaymentVm.paymentMethod()) + ? PaymentStatus.NEW : PaymentStatus.PROCESSING + ) + .paymentMethod(checkoutPaymentVm.paymentMethod()) + .amount(checkoutPaymentVm.totalAmount()) + .build(); + + Payment createdPayment = paymentRepository.save(payment); + + LOGGER.info("Payment is created successfully with ID: {}", createdPayment.getId()); + + return createdPayment.getId(); + } + + public Payment findPaymentById(Long id) { + + return paymentRepository + .findById(id) + .orElseThrow( + () -> new NotFoundException(Constants.ErrorCode.PAYMENT_NOT_FOUND, id)); + } + + public void updatePayment(Payment payment) { + + if (Objects.isNull(payment.getId())) { + throw new BadRequestException(Constants.Message.PAYMENT_ID_REQUIRED); + } + paymentRepository.save(payment); + } + } diff --git a/payment/src/main/java/com/yas/payment/service/provider/handler/PaymentHandler.java b/payment/src/main/java/com/yas/payment/service/provider/handler/PaymentHandler.java index 8dd8312a8a..180a238ed9 100644 --- a/payment/src/main/java/com/yas/payment/service/provider/handler/PaymentHandler.java +++ b/payment/src/main/java/com/yas/payment/service/provider/handler/PaymentHandler.java @@ -6,7 +6,10 @@ import com.yas.payment.viewmodel.InitPaymentRequestVm; public interface PaymentHandler { + String getProviderId(); + InitiatedPayment initPayment(InitPaymentRequestVm initPaymentRequestVm); - CapturedPayment capturePayment(CapturePaymentRequestVm capturePaymentRequestVM); + + CapturedPayment capturePayment(CapturePaymentRequestVm capturePaymentRequestVm); } diff --git a/payment/src/main/java/com/yas/payment/service/provider/handler/PaypalHandler.java b/payment/src/main/java/com/yas/payment/service/provider/handler/PaypalHandler.java index c7cd278769..732cf7e98a 100644 --- a/payment/src/main/java/com/yas/payment/service/provider/handler/PaypalHandler.java +++ b/payment/src/main/java/com/yas/payment/service/provider/handler/PaypalHandler.java @@ -46,12 +46,13 @@ public InitiatedPayment initPayment(InitPaymentRequestVm initPaymentRequestVm) { } @Override - public CapturedPayment capturePayment(CapturePaymentRequestVm capturePaymentRequestVM) { + public CapturedPayment capturePayment(CapturePaymentRequestVm capturePaymentRequestVm) { PaypalCapturePaymentRequest paypalCapturePaymentRequest = PaypalCapturePaymentRequest.builder() - .token(capturePaymentRequestVM.token()) + .token(capturePaymentRequestVm.token()) .paymentSettings(getPaymentSettings(getProviderId())) .build(); - PaypalCapturePaymentResponse paypalCapturePaymentResponse = paypalService.capturePayment(paypalCapturePaymentRequest); + PaypalCapturePaymentResponse paypalCapturePaymentResponse + = paypalService.capturePayment(paypalCapturePaymentRequest); return CapturedPayment.builder() .checkoutId(paypalCapturePaymentResponse.checkoutId()) .amount(paypalCapturePaymentResponse.amount()) diff --git a/payment/src/main/java/com/yas/payment/utils/Constants.java b/payment/src/main/java/com/yas/payment/utils/Constants.java index de7796d689..1b3be05f90 100644 --- a/payment/src/main/java/com/yas/payment/utils/Constants.java +++ b/payment/src/main/java/com/yas/payment/utils/Constants.java @@ -1,14 +1,39 @@ package com.yas.payment.utils; public final class Constants { + public final class ErrorCode { + public static final String PAYMENT_PROVIDER_NOT_FOUND = "PAYMENT_PROVIDER_NOT_FOUND"; + public static final String CANNOT_CONVERT_TO_STRING = "CANNOT_CONVERT_TO_STRING"; + public static final String ID_NOT_EXISTED = "ID_NOT_EXISTED"; + public static final String PAYMENT_NOT_FOUND = "PAYMENT_NOT_FOUND"; + public static final String ORDER_CREATION_FAILED = "ORDER_CREATION_FAILED"; private ErrorCode() { } } + public final class Column { + + private Column() { + } + + public static final String ID_COLUMN = "id"; + + // Column name of Checkout table + public static final String REDIRECT_URL_ID_COLUMN = "redirect-url"; + } + public final class Message { + + private Message() { + } + public static final String SUCCESS_MESSAGE = "SUCCESS"; + public static final String PAYMENT_METHOD_COD = "PAYMENT_METHOD_COD"; + public static final String PAYMENT_METHOD_PAYPAL = "PAYMENT_METHOD_PAYPAL"; + public static final String PAYMENT_ID_REQUIRED = "PAYMENT_ID_REQUIRED"; } + } diff --git a/payment/src/main/java/com/yas/payment/viewmodel/CheckoutPaymentVm.java b/payment/src/main/java/com/yas/payment/viewmodel/CheckoutPaymentVm.java new file mode 100644 index 0000000000..6a63624fee --- /dev/null +++ b/payment/src/main/java/com/yas/payment/viewmodel/CheckoutPaymentVm.java @@ -0,0 +1,8 @@ +package com.yas.payment.viewmodel; + +import com.yas.payment.model.enumeration.PaymentMethod; +import java.math.BigDecimal; + +public record CheckoutPaymentVm(String checkoutId, PaymentMethod paymentMethod, BigDecimal totalAmount) { + +} \ No newline at end of file diff --git a/payment/src/main/java/com/yas/payment/viewmodel/InitPaymentRequestVm.java b/payment/src/main/java/com/yas/payment/viewmodel/InitPaymentRequestVm.java index 6ac0038ef7..5173c1bdaf 100644 --- a/payment/src/main/java/com/yas/payment/viewmodel/InitPaymentRequestVm.java +++ b/payment/src/main/java/com/yas/payment/viewmodel/InitPaymentRequestVm.java @@ -1,8 +1,7 @@ package com.yas.payment.viewmodel; -import lombok.Builder; - import java.math.BigDecimal; +import lombok.Builder; @Builder public record InitPaymentRequestVm(String paymentMethod, BigDecimal totalPrice, String checkoutId) { diff --git a/payment/src/main/java/com/yas/payment/viewmodel/PaymentVm.java b/payment/src/main/java/com/yas/payment/viewmodel/PaymentVm.java new file mode 100644 index 0000000000..5f3c3eb9a0 --- /dev/null +++ b/payment/src/main/java/com/yas/payment/viewmodel/PaymentVm.java @@ -0,0 +1,34 @@ +package com.yas.payment.viewmodel; + +import com.yas.payment.model.Payment; +import com.yas.payment.model.enumeration.PaymentMethod; +import com.yas.payment.model.enumeration.PaymentStatus; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +public record PaymentVm( + @NotNull Long id, + @NotNull Long orderId, + @NotNull String checkoutId, + @NotNull BigDecimal amount, + BigDecimal paymentFee, + PaymentMethod paymentMethod, + PaymentStatus paymentStatus, + String gatewayTransactionId, + String failureMessage +) { + + public static PaymentVm fromModel(Payment payment) { + return new PaymentVm( + payment.getId(), + payment.getOrderId(), + payment.getCheckoutId(), + payment.getAmount(), + payment.getPaymentFee(), + payment.getPaymentMethod(), + payment.getPaymentStatus(), + payment.getGatewayTransactionId(), + payment.getFailureMessage() + ); + } +} \ No newline at end of file diff --git a/payment/src/main/resources/application.properties b/payment/src/main/resources/application.properties index 69efcb2188..529986d12b 100644 --- a/payment/src/main/resources/application.properties +++ b/payment/src/main/resources/application.properties @@ -44,4 +44,7 @@ resilience4j.circuitbreaker.instances.rest-circuit-breaker.failure-rate-threshol resilience4j.circuitbreaker.instances.rest-circuit-breaker.minimum-number-of-calls=5 resilience4j.circuitbreaker.instances.rest-circuit-breaker.automatic-transition-from-open-to-half-open-enabled=true resilience4j.circuitbreaker.instances.rest-circuit-breaker.permitted-number-of-calls-in-half-open-state=3 -cors.allowed-origins=* \ No newline at end of file +cors.allowed-origins=* + +cdc.event.payment.topic-name=dbpayment.public.payment +cdc.event.payment.create.group-id=payment-create \ No newline at end of file diff --git a/payment/src/main/resources/db/changelog/ddl/changelog-0004.sql b/payment/src/main/resources/db/changelog/ddl/changelog-0004.sql new file mode 100644 index 0000000000..e22215f5c3 --- /dev/null +++ b/payment/src/main/resources/db/changelog/ddl/changelog-0004.sql @@ -0,0 +1 @@ +ALTER TABLE public.payment REPLICA IDENTITY FULL; \ No newline at end of file diff --git a/payment/src/main/resources/messages/messages.properties b/payment/src/main/resources/messages/messages.properties index 7d38ef6993..dd215b1af3 100644 --- a/payment/src/main/resources/messages/messages.properties +++ b/payment/src/main/resources/messages/messages.properties @@ -1,2 +1,8 @@ SUCCESS_MESSAGE=SUCCESS PAYMENT_PROVIDER_NOT_FOUND=Payment provider {} is not found +PAYMENT_NOT_FOUND=Payment {} is not found +ORDER_CREATION_FAILED=Order creation failed with payment {} +ID_NOT_EXISTED=Payment ID is not existed +PAYMENT_METHOD_COD=Payment {} has Payment Method is COD. Will not be creating Order on external system. +PAYMENT_METHOD_PAYPAL=Payment {} has Payment Method is PayPal. Continue to create order on PayPal +PAYMENT_ID_REQUIRED=Payment ID is required. \ No newline at end of file diff --git a/payment/src/test/java/com/yas/payment/consumer/PaymentCreateConsumerTest.java b/payment/src/test/java/com/yas/payment/consumer/PaymentCreateConsumerTest.java new file mode 100644 index 0000000000..406a2f9a6e --- /dev/null +++ b/payment/src/test/java/com/yas/payment/consumer/PaymentCreateConsumerTest.java @@ -0,0 +1,162 @@ +package com.yas.payment.consumer; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gson.Gson; +import com.yas.commonlibrary.exception.BadRequestException; +import com.yas.payment.model.Payment; +import com.yas.payment.model.enumeration.PaymentMethod; +import com.yas.payment.model.enumeration.PaymentStatus; +import com.yas.payment.service.PaymentService; +import com.yas.payment.viewmodel.InitPaymentResponseVm; +import java.math.BigDecimal; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class PaymentCreateConsumerTest { + + private PaymentService paymentService; + + private PaymentCreateConsumer paymentCreateConsumer; + + @BeforeEach + void setUp() { + paymentService = mock(PaymentService.class); + Gson gson = new Gson(); + paymentCreateConsumer = new PaymentCreateConsumer(paymentService, gson); + } + + @Test + void testListen_whenConsumerRecordIsNull_shouldLogInfoAndDoNothing() { + ConsumerRecord consumerRecord = null; + + paymentCreateConsumer.listen(consumerRecord); + + verify(paymentService, never()).findPaymentById(anyLong()); + verify(paymentService, never()).updatePayment(any()); + } + + @Test + void testListen_whenValueDoesNotContainOpField_shouldNotProcessEvent() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"invalidKey\":\"value\"}"); + + paymentCreateConsumer.listen(consumerRecord); + + verify(paymentService, never()).findPaymentById(anyLong()); + verify(paymentService, never()).updatePayment(any()); + } + + @Test + void testListen_whenEventIsCreateAndPaymentMethodIsNotPaypal_shouldNotCreateOrderOnPaypal() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"c\", \"after\":{\"id\":1234}}"); + + Payment payment = new Payment(); + payment.setId(1234L); + payment.setPaymentMethod(PaymentMethod.BANKING); + payment.setAmount(new BigDecimal("100")); + payment.setCheckoutId("CHECKOUT123"); + + when(paymentService.findPaymentById(1234L)).thenReturn(payment); + + InitPaymentResponseVm responseVm = new InitPaymentResponseVm( + "success", + "PAYMENT123", + "http://localhost:8080/test" + ); + when(paymentService.initPayment(any())).thenReturn(responseVm); + paymentCreateConsumer.listen(consumerRecord); + verify(paymentService, never()).updatePayment(any()); + } + + @Test + void testListen_whenEventIsCreateAndPaymentMethodIsPaypal_shouldCreateOrderOnPaypal() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"c\", \"after\":{\"id\":123}}"); + + Payment payment = new Payment(); + payment.setId(123L); + payment.setPaymentMethod(PaymentMethod.PAYPAL); + payment.setAmount(new BigDecimal("100")); + payment.setCheckoutId("CHECKOUT123"); + + when(paymentService.findPaymentById(123L)).thenReturn(payment); + + InitPaymentResponseVm responseVm = new InitPaymentResponseVm( + "success", + "PAYMENT123", + "http://localhost:8080/test" + ); + when(paymentService.initPayment(any())).thenReturn(responseVm); + + paymentCreateConsumer.listen(consumerRecord); + + ArgumentCaptor paymentCaptor = ArgumentCaptor.forClass(Payment.class); + verify(paymentService).updatePayment(paymentCaptor.capture()); + + Payment updatedPayment = paymentCaptor.getValue(); + Assertions.assertEquals(PaymentStatus.PROCESSING, updatedPayment.getPaymentStatus()); + Assertions.assertEquals("PAYMENT123", updatedPayment.getPaymentProviderCheckoutId()); + } + + @Test + void testListen_whenEventResponseIsFailed_shouldNotUpdatePayment() { + + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"c\", \"after\":{\"id\":123}}"); + + Payment payment = new Payment(); + payment.setId(123L); + payment.setPaymentMethod(PaymentMethod.PAYPAL); + payment.setAmount(new BigDecimal("100")); + payment.setCheckoutId("CHECKOUT123"); + + when(paymentService.findPaymentById(123L)).thenReturn(payment); + + InitPaymentResponseVm responseVm = new InitPaymentResponseVm( + "failed", + "PAYMENT123", + "http://localhost:8080/test" + ); + when(paymentService.initPayment(any())).thenReturn(responseVm); + + paymentCreateConsumer.listen(consumerRecord); + + verify(paymentService, never()).updatePayment(any()); + } + + @Test + void testListen_whenEventIsCreateAndPaymentMethodIsCOD_shouldNotCreateOrderOnPaypal() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"c\", \"after\":{\"id\":123}}"); + + Payment payment = new Payment(); + payment.setId(123L); + payment.setPaymentMethod(PaymentMethod.COD); + + when(paymentService.findPaymentById(123L)).thenReturn(payment); + + paymentCreateConsumer.listen(consumerRecord); + + verify(paymentService, never()).initPayment(any()); + } + + @Test + void testListen_whenPaymentIdDoesNotExist_shouldThrowBadRequestException() { + ConsumerRecord consumerRecord = mock(ConsumerRecord.class); + when(consumerRecord.value()).thenReturn("{\"op\":\"c\", \"after\":{}}"); + + assertThrows(BadRequestException.class, () -> paymentCreateConsumer.listen(consumerRecord)); + } +} diff --git a/payment/src/test/java/com/yas/payment/service/PaymentServiceTest.java b/payment/src/test/java/com/yas/payment/service/PaymentServiceTest.java index 26053a631f..82aad08056 100644 --- a/payment/src/test/java/com/yas/payment/service/PaymentServiceTest.java +++ b/payment/src/test/java/com/yas/payment/service/PaymentServiceTest.java @@ -1,5 +1,15 @@ package com.yas.payment.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yas.commonlibrary.exception.NotFoundException; import com.yas.payment.model.CapturedPayment; import com.yas.payment.model.InitiatedPayment; import com.yas.payment.model.Payment; @@ -7,22 +17,19 @@ import com.yas.payment.model.enumeration.PaymentStatus; import com.yas.payment.repository.PaymentRepository; import com.yas.payment.service.provider.handler.PaymentHandler; -import com.yas.payment.service.provider.handler.PaypalHandler; -import com.yas.payment.viewmodel.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - +import com.yas.payment.viewmodel.CapturePaymentRequestVm; +import com.yas.payment.viewmodel.CapturePaymentResponseVm; +import com.yas.payment.viewmodel.CheckoutPaymentVm; +import com.yas.payment.viewmodel.InitPaymentRequestVm; +import com.yas.payment.viewmodel.InitPaymentResponseVm; +import com.yas.payment.viewmodel.PaymentOrderStatusVm; import java.math.BigDecimal; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class PaymentServiceTest { private PaymentRepository paymentRepository; @@ -70,19 +77,63 @@ void initPayment_Success() { @Test void capturePayment_Success() { - CapturePaymentRequestVm capturePaymentRequestVM = CapturePaymentRequestVm.builder() + CapturePaymentRequestVm capturePaymentRequestVm = CapturePaymentRequestVm.builder() .paymentMethod(PaymentMethod.PAYPAL.name()).token("123").build(); CapturedPayment capturedPayment = prepareCapturedPayment(); Long orderId = 999L; - when(paymentHandler.capturePayment(capturePaymentRequestVM)).thenReturn(capturedPayment); + when(paymentHandler.capturePayment(capturePaymentRequestVm)).thenReturn(capturedPayment); when(orderService.updateCheckoutStatus(capturedPayment)).thenReturn(orderId); when(paymentRepository.save(any())).thenReturn(payment); - CapturePaymentResponseVm capturePaymentResponseVm = paymentService.capturePayment(capturePaymentRequestVM); + CapturePaymentResponseVm capturePaymentResponseVm = paymentService.capturePayment(capturePaymentRequestVm); verifyPaymentCreation(capturePaymentResponseVm); verifyOrderServiceInteractions(capturedPayment); verifyResult(capturedPayment, capturePaymentResponseVm); } + @Test + void testCreatePaymentFromEvent() { + + CheckoutPaymentVm checkoutPaymentRequestDto = new CheckoutPaymentVm( + "123", + PaymentMethod.PAYPAL, + new BigDecimal(12) + ); + + Payment actualPayment = Payment.builder().id(1L).paymentMethod(PaymentMethod.PAYPAL).build(); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Payment.class); + when(paymentRepository.save(argumentCaptor.capture())).thenReturn(actualPayment); + + Long result = paymentService.createPaymentFromEvent(checkoutPaymentRequestDto); + + assertThat(result).isEqualTo(1); + Payment captorValue = argumentCaptor.getValue(); + assertThat(captorValue.getPaymentMethod()).isEqualTo(PaymentMethod.PAYPAL); + assertThat(captorValue.getPaymentStatus()).isEqualTo(PaymentStatus.PROCESSING); + assertThat(captorValue.getAmount()).isEqualTo(new BigDecimal(12)); + + } + + @Test + void testFindPaymentById_whenNormalCase_methodSuccess() { + when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); + Payment actual = paymentService.findPaymentById(1L); + assertThat(actual).isNotNull(); + } + + @Test + void testFindPaymentById_whenNotFound_throwNotFoundException() { + when(paymentRepository.findById(1L)).thenReturn(Optional.empty()); + NotFoundException foundException + = assertThrows(NotFoundException.class, () -> paymentService.findPaymentById(1L)); + assertThat(foundException.getMessage()).isEqualTo("Payment 1 is not found"); + } + + @Test + void testUpdatePayment_whenNormalCase_methodSuccess() { + paymentService.updatePayment(payment); + verify(paymentRepository, times(1)).save(any()); + } + private CapturedPayment prepareCapturedPayment() { return CapturedPayment.builder() .orderId(2L) diff --git a/start-source-connectors.sh b/start-source-connectors.sh index 8cc7fcafde..84e00f1a06 100644 --- a/start-source-connectors.sh +++ b/start-source-connectors.sh @@ -5,4 +5,12 @@ curl -i -X PUT -H "Content-Type:application/json" \ curl -i -X PUT -H "Content-Type:application/json" \ http://localhost:8083/connectors/order-connector/config \ - -d @kafka/connects/debezium-order.json \ No newline at end of file + -d @kafka/connects/debezium-order.json + +curl -i -X PUT -H "Content-Type:application/json" \ + http://localhost:8083/connectors/checkout-connector/config \ + -d @kafka/connects/debezium-checkout-status.json + +curl -i -X PUT -H "Content-Type:application/json" \ + http://localhost:8083/connectors/payment-connector/config \ + -d @kafka/connects/debezium-payment.json \ No newline at end of file