diff --git a/.dockerignore b/.dockerignore index 7fa9360..16ac0c2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,7 +23,6 @@ deploy/ *.log # Environment files -deploy/.env *.env # Documentation diff --git a/.gitignore b/.gitignore index da8f210..23e83a3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,5 @@ build/ ### VS Code ### .vscode/ -/deploy/.env -.env \ No newline at end of file +.env +deploy/.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c707155..6021617 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/src/main/resources/db/changelog /app/resources/db/changelog +COPY --from=build /app/src/main/resources/templates /app/resources/templates EXPOSE 6091 ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/deploy/.env.example b/deploy/.env.example index 89a7e05..c1fcff0 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -4,23 +4,18 @@ DATASOURCE_USERNAME=commercifyapp DATASOURCE_PASSWORD=password123! STRIPE_SECRET_TEST_KEY= STRIPE_WEBHOOK_SECRET= +STRIPE_WEBHOOK_ENDPOINT=https:///api/v2/payments/webhooks/stripe/callback JWT_SECRET_KEY= ADMIN_EMAIL=admin@commercify.app -ADMIN_PASSWORD=commercifyadmin123! +ADMIN_PASSWORD=admin MOBILEPAY_CLIENT_ID= MOBILEPAY_CLIENT_SECRET= MOBILEPAY_SUBSCRIPTION_KEY= MOBILEPAY_MERCHANT_ID= -MOBILEPAY_API_URL=https://apitest.vipps.no +MOBILEPAY_API_URL= MOBILEPAY_SYSTEM_NAME=Commercify -# used for outgoing emails -MAIL_HOST=smtp.gmail.com -MAIL_PORT=587 +MOBILEPAY_WEBHOOK_CALLBACK=https:///api/v2/payments/webhooks/mobilepay/callback MAIL_USERNAME= MAIL_PASSWORD= -# used for email confirmation token (WIP) -FRONTEND_URL=http://localhost:3000 -# The email address that will receive new order notifications emails -ORDER_EMAIL_RECEIVER= -# used for mobilepay webhook callback -BACKEND_HOST= \ No newline at end of file +MAIL_HOST=smtp.ethereal.email +MAIL_PORT=587 \ No newline at end of file diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5d7fb96..3769602 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -29,6 +29,8 @@ services: - SPRING_DATASOURCE_URL=${DATASOURCE_URL} - SPRING_DATASOURCE_USERNAME=${DATASOURCE_USERNAME} - SPRING_DATASOURCE_PASSWORD=${DATASOURCE_PASSWORD} + - STRIPE_SECRET_TEST_KEY=${STRIPE_SECRET_TEST_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - JWT_SECRET_KEY=${JWT_SECRET_KEY} - ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} @@ -38,15 +40,11 @@ services: - MOBILEPAY_SUBSCRIPTION_KEY=${MOBILEPAY_SUBSCRIPTION_KEY} - MOBILEPAY_API_URL=${MOBILEPAY_API_URL} - MOBILEPAY_SYSTEM_NAME=${MOBILEPAY_SYSTEM_NAME} - - STRIPE_SECRET_TEST_KEY=${STRIPE_SECRET_TEST_KEY} - - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - - MAIL_USERNAME=${MAIL_USERNAME} - - MAIL_PASSWORD=${MAIL_PASSWORD} + - MOBILEPAY_WEBHOOK_CALLBACK=${MOBILEPAY_WEBHOOK_CALLBACK} - MAIL_HOST=${MAIL_HOST} - MAIL_PORT=${MAIL_PORT} - - ORDER_EMAIL_RECEIVER=${ORDER_EMAIL_RECEIVER} - - FRONTEND_URL=${FRONTEND_URL} - - BACKEND_HOST=${BACKEND_HOST} + - MAIL_USERNAME=${MAIL_USERNAME} + - MAIL_PASSWORD=${MAIL_PASSWORD} depends_on: mysql: condition: service_healthy diff --git a/example.env b/example.env new file mode 100644 index 0000000..96c1b2e --- /dev/null +++ b/example.env @@ -0,0 +1,20 @@ +DATASOURCE_URL=jdbc:mysql://localhost/commercifydb?createDatabaseIfNotExist=true +DATASOURCE_USERNAME= +DATASOURCE_PASSWORD= +STRIPE_SECRET_TEST_KEY= +STRIPE_WEBHOOK_SECRET= +JWT_SECRET_KEY=7581e8477a88733917bc3b48f683a827935a492a0bd976a59429a72f28c71fd3 +ADMIN_EMAIL= +ADMIN_PASSWORD=commercifyadmin123! +ADMIN_ORDER_DASHBOARD=https:///admin/orders +MOBILEPAY_CLIENT_ID= +MOBILEPAY_CLIENT_SECRET= +MOBILEPAY_SUBSCRIPTION_KEY= +MOBILEPAY_MERCHANT_ID= +MOBILEPAY_API_URL= +MOBILEPAY_SYSTEM_NAME= +MOBILEPAY_WEBHOOK_CALLBACK=https:///api/v2/payments/webhooks/mobilepay/callback +MAIL_HOST=smtp.gmail.com +MAIL_PORT= +MAIL_USERNAME= +MAIL_PASSWORD= \ No newline at end of file diff --git a/pom.xml b/pom.xml index f723a7c..9eec35d 100644 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ com.stripe stripe-java - 26.11.0 + 28.3.0 compile @@ -154,6 +154,10 @@ org.thymeleaf.extras thymeleaf-extras-springsecurity6 + + org.springframework.boot + spring-boot-starter-validation + @@ -172,7 +176,28 @@ + + + cz.habarta.typescript-generator + typescript-generator-maven-plugin + 3.2.1263 + + + generate + + generate + + process-classes + + + + jackson2 + module + + com.zenfulcode.commercify.api.**.dto.** + + + - diff --git a/src/main/java/com/zenfulcode/commercify/commercify/CommercifyApplication.java b/src/main/java/com/zenfulcode/commercify/CommercifyApplication.java similarity index 56% rename from src/main/java/com/zenfulcode/commercify/commercify/CommercifyApplication.java rename to src/main/java/com/zenfulcode/commercify/CommercifyApplication.java index 96b8800..6c169bc 100644 --- a/src/main/java/com/zenfulcode/commercify/commercify/CommercifyApplication.java +++ b/src/main/java/com/zenfulcode/commercify/CommercifyApplication.java @@ -1,12 +1,18 @@ -package com.zenfulcode.commercify.commercify; +package com.zenfulcode.commercify; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestTemplate; @SpringBootApplication public class CommercifyApplication { public static void main(String[] args) { SpringApplication.run(CommercifyApplication.class, args); } -} + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/AuthController.java b/src/main/java/com/zenfulcode/commercify/api/auth/AuthController.java new file mode 100644 index 0000000..f73573c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/AuthController.java @@ -0,0 +1,82 @@ +package com.zenfulcode.commercify.api.auth; + +import com.zenfulcode.commercify.api.auth.dto.request.LoginRequest; +import com.zenfulcode.commercify.api.auth.dto.request.RefreshTokenRequest; +import com.zenfulcode.commercify.api.auth.dto.request.RegisterRequest; +import com.zenfulcode.commercify.api.auth.dto.response.AuthResponse; +import com.zenfulcode.commercify.api.auth.dto.response.NextAuthResponse; +import com.zenfulcode.commercify.auth.application.service.AuthenticationApplicationService; +import com.zenfulcode.commercify.auth.application.service.AuthenticationResult; +import com.zenfulcode.commercify.auth.domain.exception.InvalidAuthenticationException; +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import com.zenfulcode.commercify.user.application.service.UserApplicationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/v2/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthenticationApplicationService authService; + private final UserApplicationService userService; + + @PostMapping("/nextauth") + public ResponseEntity> nextAuthSignIn(@RequestBody LoginRequest request) { + log.info("Next auth request: {}", request); + + // Authenticate through the application service + AuthenticationResult result = authService.authenticate(request.toCommand()); + + // Create and return the NextAuth response + return ResponseEntity.ok(ApiResponse.success(NextAuthResponse.from(result))); + } + + @GetMapping("/session") + public ResponseEntity> validateSession(@RequestHeader("Authorization") String authHeader) { + try { + // Extract token using a domain service method + String token = authService.extractTokenFromHeader(authHeader).orElseThrow(() -> new InvalidAuthenticationException("Invalid authorization header")); + + // Validate token through the application service + AuthenticatedUser user = authService.validateAccessToken(token); + + // Create and return the NextAuth session response + return ResponseEntity.ok(ApiResponse.success(NextAuthResponse.fromUser(user))); + } catch (InvalidAuthenticationException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + + @PostMapping("/signin") + public ResponseEntity> login(@RequestBody LoginRequest request) { + AuthenticationResult result = authService.authenticate(request.toCommand()); + + AuthResponse response = AuthResponse.from(result); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PostMapping("/signup") + public ResponseEntity> register(@RequestBody RegisterRequest request) { + + userService.registerUser(request.firstName(), request.lastName(), request.email(), request.password(), request.phone()); + + // Authenticate the newly registered user + AuthenticationResult result = authService.authenticate(new LoginRequest(request.email(), request.password(), false).toCommand()); + + AuthResponse response = AuthResponse.from(result); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PostMapping("/refresh") + public ResponseEntity> refreshToken(@RequestBody RefreshTokenRequest request) { + + AuthenticationResult result = authService.refreshToken(request.refreshToken()); + AuthResponse response = AuthResponse.from(result); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/LoginRequest.java b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..4276f41 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/LoginRequest.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.api.auth.dto.request; + +import com.zenfulcode.commercify.auth.application.command.LoginCommand; + +public record LoginRequest( + String email, + String password, + Boolean isGuest +) { + public LoginCommand toCommand() { + return new LoginCommand(email, password, isGuest != null && isGuest); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RefreshTokenRequest.java b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RefreshTokenRequest.java new file mode 100644 index 0000000..419f2cf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RefreshTokenRequest.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.api.auth.dto.request; + +public record RefreshTokenRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RegisterRequest.java b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RegisterRequest.java new file mode 100644 index 0000000..e839a74 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/dto/request/RegisterRequest.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.api.auth.dto.request; + +public record RegisterRequest( + String firstName, + String lastName, + String email, + String password, + String phone +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/AuthResponse.java b/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/AuthResponse.java new file mode 100644 index 0000000..c299a80 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/AuthResponse.java @@ -0,0 +1,33 @@ +package com.zenfulcode.commercify.api.auth.dto.response; + +import com.zenfulcode.commercify.auth.application.service.AuthenticationResult; +import com.zenfulcode.commercify.auth.domain.model.UserRole; + +import java.util.Set; +import java.util.stream.Collectors; + +public record AuthResponse( + String accessToken, + String refreshToken, + String tokenType, + String userId, + String username, + String email, + Set roles +) { + public static AuthResponse from(AuthenticationResult result) { + Set roles = result.user().getRoles().stream() + .map(UserRole::name) + .collect(Collectors.toSet()); + + return new AuthResponse( + result.accessToken(), + result.refreshToken(), + "Bearer", + result.user().getUserId().toString(), + result.user().getUsername(), + result.user().getEmail(), + roles + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/NextAuthResponse.java b/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/NextAuthResponse.java new file mode 100644 index 0000000..25b1a14 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/auth/dto/response/NextAuthResponse.java @@ -0,0 +1,39 @@ +package com.zenfulcode.commercify.api.auth.dto.response; + +import com.zenfulcode.commercify.auth.application.service.AuthenticationResult; +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.auth.domain.model.UserRole; + +import java.util.Set; + +public record NextAuthResponse( + String id, + String name, + String email, + String accessToken, + String refreshToken, + Set roles +) { + public static NextAuthResponse from(AuthenticationResult result) { + AuthenticatedUser user = result.user(); + return new NextAuthResponse( + user.getUserId().toString(), + user.getUsername(), + user.getEmail(), + result.accessToken(), + result.refreshToken(), + user.getRoles() + ); + } + + public static NextAuthResponse fromUser(AuthenticatedUser user) { + return new NextAuthResponse( + user.getUserId().toString(), + user.getUsername(), + user.getEmail(), + null, + null, + user.getRoles() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/OrderController.java b/src/main/java/com/zenfulcode/commercify/api/order/OrderController.java new file mode 100644 index 0000000..8ea7ffe --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/OrderController.java @@ -0,0 +1,127 @@ +package com.zenfulcode.commercify.api.order; + +import com.zenfulcode.commercify.api.order.dto.request.CreateOrderRequest; +import com.zenfulcode.commercify.api.order.dto.response.CreateOrderResponse; +import com.zenfulcode.commercify.api.order.dto.response.OrderDetailsResponse; +import com.zenfulcode.commercify.api.order.dto.response.PagedOrderResponse; +import com.zenfulcode.commercify.api.order.mapper.OrderDtoMapper; +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.order.application.command.CancelOrderCommand; +import com.zenfulcode.commercify.order.application.command.CreateOrderCommand; +import com.zenfulcode.commercify.order.application.command.GetOrderByIdCommand; +import com.zenfulcode.commercify.order.application.dto.OrderDetailsDTO; +import com.zenfulcode.commercify.order.application.query.FindAllOrdersQuery; +import com.zenfulcode.commercify.order.application.query.FindOrdersByUserIdQuery; +import com.zenfulcode.commercify.order.application.service.OrderApplicationService; +import com.zenfulcode.commercify.order.domain.exception.UnauthorizedOrderCreationException; +import com.zenfulcode.commercify.order.domain.exception.UnauthorizedOrderFetchingException; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v2/orders") +@RequiredArgsConstructor +public class OrderController { + private final OrderApplicationService orderApplicationService; + private final OrderDtoMapper orderDtoMapper; + + @PostMapping + public ResponseEntity> createOrder( + @RequestBody CreateOrderRequest request, + Authentication authentication) { + AuthenticatedUser user = (AuthenticatedUser) authentication.getPrincipal(); + if (isNotUserAuthorized(user, request.getUserId().getId())) { + throw new UnauthorizedOrderCreationException(request.getUserId()); + } + + // Convert request to command + CreateOrderCommand command = orderDtoMapper.toCommand(request); + + // Create order through application service + OrderId orderId = orderApplicationService.createOrder(command); + + // Create and return response + CreateOrderResponse response = new CreateOrderResponse( + orderId.toString(), + "Order created successfully" + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/{orderId}") + public ResponseEntity> getOrder( + @PathVariable String orderId, + Authentication authentication) { + GetOrderByIdCommand command = orderDtoMapper.toCommand(orderId); + + OrderDetailsDTO order = orderApplicationService.getOrderDetailsById(command); + AuthenticatedUser user = (AuthenticatedUser) authentication.getPrincipal(); + + if (isNotUserAuthorized(user, order.userId().getId())) { + throw new UnauthorizedOrderFetchingException(user.getUserId().getId()); + } + + OrderDetailsResponse response = orderDtoMapper.toResponse(order); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/user/{userId}") + public ResponseEntity> getOrdersByUserId( + @PathVariable String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + Authentication authentication) { + AuthenticatedUser user = (AuthenticatedUser) authentication.getPrincipal(); + + if (isNotUserAuthorized(user, userId)) { + throw new UnauthorizedOrderFetchingException(userId); + } + + FindOrdersByUserIdQuery query = new FindOrdersByUserIdQuery( + UserId.of(userId), + PageRequest.of(page, size) + ); + + Page orders = orderApplicationService.findOrdersByUserId(query); + PagedOrderResponse response = orderDtoMapper.toPagedResponse(orders); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllOrders( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + FindAllOrdersQuery query = new FindAllOrdersQuery(PageRequest.of(page, size)); + + Page orders = orderApplicationService.findAllOrders(query); + PagedOrderResponse response = orderDtoMapper.toPagedResponse(orders); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @DeleteMapping("/{orderId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> cancelOrder(@PathVariable String orderId) { + CancelOrderCommand command = new CancelOrderCommand(OrderId.of(orderId)); + + orderApplicationService.cancelOrder(command); + + return ResponseEntity.ok(ApiResponse.success("Order cancelled successfully")); + } + + + private boolean isNotUserAuthorized(AuthenticatedUser user, String userId) { + return !user.getUserId().getId().equals(userId) && !user.isAdmin(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/request/AddressRequest.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/AddressRequest.java new file mode 100644 index 0000000..f6ca1c8 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/AddressRequest.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.api.order.dto.request; + +public record AddressRequest( + String street, + String city, + String state, + String zipCode, + String country +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderLineRequest.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderLineRequest.java new file mode 100644 index 0000000..7367a5e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderLineRequest.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.order.dto.request; + + +public record CreateOrderLineRequest( + String productId, + String variantId, + int quantity +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderRequest.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderRequest.java new file mode 100644 index 0000000..7f24ff6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CreateOrderRequest.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.api.order.dto.request; + +import com.zenfulcode.commercify.user.domain.valueobject.UserId; + +import java.util.List; + +public record CreateOrderRequest( + String userId, + String currency, + CustomerDetailsRequest customerDetails, + AddressRequest shippingAddress, + AddressRequest billingAddress, + List orderLines +) { + public UserId getUserId() { + return UserId.of(userId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CustomerDetailsRequest.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CustomerDetailsRequest.java new file mode 100644 index 0000000..2d278ca --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/CustomerDetailsRequest.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.order.dto.request; + +public record CustomerDetailsRequest( + String firstName, + String lastName, + String email, + String phone +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/request/UpdateOrderStatusRequest.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/UpdateOrderStatusRequest.java new file mode 100644 index 0000000..1d38262 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/request/UpdateOrderStatusRequest.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.api.order.dto.request; + +public record UpdateOrderStatusRequest( + String status +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/AddressResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/AddressResponse.java new file mode 100644 index 0000000..4a313c9 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/AddressResponse.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +public record AddressResponse( + String street, + String city, + String state, + String zipCode, + String country +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CreateOrderResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CreateOrderResponse.java new file mode 100644 index 0000000..add682a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CreateOrderResponse.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +public record CreateOrderResponse( + String id, + String message +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CustomerDetailsResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CustomerDetailsResponse.java new file mode 100644 index 0000000..87465f1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/CustomerDetailsResponse.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +public record CustomerDetailsResponse( + String firstName, + String lastName, + String email, + String phone +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderDetailsResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderDetailsResponse.java new file mode 100644 index 0000000..0e53c6b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderDetailsResponse.java @@ -0,0 +1,19 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.time.Instant; +import java.util.List; + +public record OrderDetailsResponse( + String id, + String userId, + String status, + Money totalAmount, + List orderLines, + CustomerDetailsResponse customerDetails, + AddressResponse shippingAddress, + AddressResponse billingAddress, + Instant createdAt +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderLineResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderLineResponse.java new file mode 100644 index 0000000..29355c0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderLineResponse.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record OrderLineResponse( + String id, + ProductId productId, + VariantId variantId, + int quantity, + Money unitPrice, + Money total +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderSummaryResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderSummaryResponse.java new file mode 100644 index 0000000..9346994 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/OrderSummaryResponse.java @@ -0,0 +1,16 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.time.Instant; + +public record OrderSummaryResponse( + String id, + String userId, + String customerName, + String status, + int orderLineAmount, + Money totalAmount, + Instant createdAt +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/dto/response/PagedOrderResponse.java b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/PagedOrderResponse.java new file mode 100644 index 0000000..878b8cf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/dto/response/PagedOrderResponse.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.api.order.dto.response; + +import com.zenfulcode.commercify.api.product.dto.response.PageInfo; + +import java.util.List; + +public record PagedOrderResponse( + List items, + PageInfo pageInfo +) {} diff --git a/src/main/java/com/zenfulcode/commercify/api/order/mapper/OrderDtoMapper.java b/src/main/java/com/zenfulcode/commercify/api/order/mapper/OrderDtoMapper.java new file mode 100644 index 0000000..c9dfedd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/order/mapper/OrderDtoMapper.java @@ -0,0 +1,150 @@ +package com.zenfulcode.commercify.api.order.mapper; + +import com.zenfulcode.commercify.api.order.dto.request.CreateOrderLineRequest; +import com.zenfulcode.commercify.api.order.dto.request.CreateOrderRequest; +import com.zenfulcode.commercify.api.order.dto.response.*; +import com.zenfulcode.commercify.api.product.dto.response.PageInfo; +import com.zenfulcode.commercify.order.application.command.CreateOrderCommand; +import com.zenfulcode.commercify.order.application.command.GetOrderByIdCommand; +import com.zenfulcode.commercify.order.application.dto.OrderDetailsDTO; +import com.zenfulcode.commercify.order.application.dto.OrderLineDTO; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.Address; +import com.zenfulcode.commercify.order.domain.valueobject.CustomerDetails; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineDetails; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class OrderDtoMapper { + public CreateOrderCommand toCommand(CreateOrderRequest request) { + CustomerDetails customerDetails = new CustomerDetails( + request.customerDetails().firstName(), + request.customerDetails().lastName(), + request.customerDetails().email(), + request.customerDetails().phone() + ); + + Address shippingAddress = new Address( + request.shippingAddress().street(), + request.shippingAddress().city(), + request.shippingAddress().state(), + request.shippingAddress().zipCode(), + request.shippingAddress().country() + ); + + Address billingAddress = request.billingAddress() != null ? new Address( + request.billingAddress().street(), + request.billingAddress().city(), + request.billingAddress().state(), + request.billingAddress().zipCode(), + request.billingAddress().country() + ) : null; + + List orderLines = request.orderLines().stream() + .map(this::toOrderLineDetails) + .toList(); + + return new CreateOrderCommand( + UserId.of(request.userId()), + request.currency(), + customerDetails, + shippingAddress, + billingAddress, + orderLines + ); + } + + public GetOrderByIdCommand toCommand(String orderId) { + return new GetOrderByIdCommand(orderId); + } + + private OrderLineDetails toOrderLineDetails(CreateOrderLineRequest request) { + return new OrderLineDetails( + ProductId.of(request.productId()), + VariantId.of(request.variantId()), + request.quantity() + ); + } + + public OrderDetailsResponse toResponse(OrderDetailsDTO dto) { + return new OrderDetailsResponse( + dto.id().toString(), + dto.userId().toString(), + dto.status().toString(), + dto.totalAmount(), + dto.orderLines().stream() + .map(this::toOrderLineResponse) + .collect(Collectors.toList()), + toCustomerDetailsResponse(dto.customerDetails()), + toAddressResponse(dto.shippingAddress()), + dto.billingAddress() != null ? + toAddressResponse(dto.billingAddress()) : null, + dto.createdAt() + ); + } + + public PagedOrderResponse toPagedResponse(Page orderPage) { + List orders = orderPage.getContent().stream() + .map(this::toSummaryResponse) + .collect(Collectors.toList()); + + return new PagedOrderResponse( + orders, + new PageInfo( + orderPage.getNumber(), + orderPage.getSize(), + orderPage.getTotalElements(), + orderPage.getTotalPages() + ) + ); + } + + private OrderSummaryResponse toSummaryResponse(Order order) { + return new OrderSummaryResponse( + order.getId().toString(), + order.getUser().getId().toString(), + order.getOrderShippingInfo().getCustomerName(), + order.getStatus().toString(), + order.getOrderLines().size(), + order.getTotalAmount(), + order.getCreatedAt() + ); + } + + private OrderLineResponse toOrderLineResponse(OrderLineDTO line) { + return new OrderLineResponse( + line.id().toString(), + line.productId(), + line.variantId(), + line.quantity(), + line.unitPrice(), + line.total() + ); + } + + private CustomerDetailsResponse toCustomerDetailsResponse(CustomerDetails details) { + return new CustomerDetailsResponse( + details.firstName(), + details.lastName(), + details.email(), + details.phone() + ); + } + + private AddressResponse toAddressResponse(Address address) { + return new AddressResponse( + address.street(), + address.city(), + address.state(), + address.zipCode(), + address.country() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/PaymentAdminController.java b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentAdminController.java new file mode 100644 index 0000000..9d8606b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentAdminController.java @@ -0,0 +1,43 @@ +package com.zenfulcode.commercify.api.payment; + +import com.zenfulcode.commercify.api.payment.dto.response.CapturedPaymentResponse; +import com.zenfulcode.commercify.api.payment.mapper.PaymentDtoMapper; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.application.command.CapturePaymentCommand; +import com.zenfulcode.commercify.payment.application.dto.CapturedPayment; +import com.zenfulcode.commercify.payment.application.service.PaymentApplicationService; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v2/payments/admin") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +public class PaymentAdminController { + private final PaymentApplicationService paymentService; + private final PaymentDtoMapper paymentDtoMapper; + + @PostMapping("/{orderId}/capture") + public ResponseEntity> capturePayment( + @PathVariable String orderId) { + + CapturePaymentCommand command = paymentDtoMapper.toCaptureCommand(OrderId.of(orderId)); + CapturedPayment capturedPayment = paymentService.capturePayment(command); + CapturedPaymentResponse response = paymentDtoMapper.toCapturedResponse(capturedPayment); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PostMapping("/{orderId}/refund") + public ResponseEntity> refundPayment( + @PathVariable String orderId) { + +// paymentService.refundPayment(OrderId.of(orderId)); + return ResponseEntity.ok(ApiResponse.success("Refund initiated")); + } + +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/PaymentController.java b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentController.java new file mode 100644 index 0000000..da265a0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentController.java @@ -0,0 +1,33 @@ +package com.zenfulcode.commercify.api.payment; + +import com.zenfulcode.commercify.api.payment.mapper.PaymentDtoMapper; +import com.zenfulcode.commercify.api.payment.dto.request.InitiatePaymentRequest; +import com.zenfulcode.commercify.api.payment.dto.response.PaymentResponse; +import com.zenfulcode.commercify.payment.application.command.InitiatePaymentCommand; +import com.zenfulcode.commercify.payment.application.dto.InitializedPayment; +import com.zenfulcode.commercify.payment.application.service.PaymentApplicationService; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v2/payments") +@RequiredArgsConstructor +public class PaymentController { + private final PaymentApplicationService paymentService; + private final PaymentDtoMapper paymentDtoMapper; + + @PostMapping("/initiate") + public ResponseEntity> initiatePayment( + @RequestBody InitiatePaymentRequest request) { + + InitiatePaymentCommand command = paymentDtoMapper.toCommand(request); + InitializedPayment response = paymentService.initiatePayment(command); + + return ResponseEntity.ok(ApiResponse.success(paymentDtoMapper.toResponse(response))); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/PaymentWebhookController.java b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentWebhookController.java new file mode 100644 index 0000000..13a3f3d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/PaymentWebhookController.java @@ -0,0 +1,86 @@ +package com.zenfulcode.commercify.api.payment; + +import com.zenfulcode.commercify.api.payment.dto.request.MobilepayWebhookRegistrationRequest; +import com.zenfulcode.commercify.payment.application.service.MobilepayWebhookService; +import com.zenfulcode.commercify.payment.application.service.PaymentApplicationService; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.valueobject.WebhookRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v2/payments/webhooks") +@RequiredArgsConstructor +@Slf4j +public class PaymentWebhookController { + private final PaymentApplicationService paymentService; + private final MobilepayWebhookService webhookService; + + @PostMapping("/{provider}/callback") + public ResponseEntity> handleCallback( + @PathVariable String provider, + @RequestBody String body, + HttpServletRequest request + ) { + PaymentProvider paymentProvider = paymentService.getPaymentProvider(provider); + + WebhookRequest webhookRequest = WebhookRequest.builder() + .body(body) + .headers(extractHeaders(request)) + .build(); + + log.info("Handling webhook callback for provider: {}", provider); + + try { + WebhookPayload payload = webhookService.authenticate(paymentProvider, webhookRequest); + paymentService.handlePaymentCallback(paymentProvider, payload); + + return ResponseEntity.ok(ApiResponse.success("Webhook processed successfully")); + } catch (Exception e) { + log.error("Error handling webhook callback", e); + return ResponseEntity.badRequest().body(ApiResponse.error("Error handling webhook callback", "SERVER_ERROR", 500)); + } + } + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/{provider}") + public ResponseEntity> registerWebhook( + @PathVariable String provider, + @RequestBody MobilepayWebhookRegistrationRequest request + ) { + PaymentProvider paymentProvider = paymentService.getPaymentProvider(provider); + webhookService.registerWebhook(paymentProvider, request.callbackUrl()); + return ResponseEntity.ok(ApiResponse.success("Webhook registered successfully")); + } + + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/{provider}") + public ResponseEntity> getWebhooks(@PathVariable String provider) { + PaymentProvider paymentProvider = paymentService.getPaymentProvider(provider); + Object webhooks = webhookService.getWebhooks(paymentProvider); + return ResponseEntity.ok(ApiResponse.success(webhooks)); + } + + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{provider}/{webhookId}") + public ResponseEntity> getWebhooks(@PathVariable String provider, @PathVariable String webhookId) { + PaymentProvider paymentProvider = paymentService.getPaymentProvider(provider); + webhookService.deleteWebhook(paymentProvider, webhookId); + return ResponseEntity.ok(ApiResponse.success("Webhook deleted successfully")); + } + + private Map extractHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + request.getHeaderNames().asIterator().forEachRemaining(name -> headers.put(name, request.getHeader(name))); + return headers; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/InitiatePaymentRequest.java b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/InitiatePaymentRequest.java new file mode 100644 index 0000000..304e864 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/InitiatePaymentRequest.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.api.payment.dto.request; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; + +public record InitiatePaymentRequest( + String orderId, + String provider, + PaymentDetailsRequest paymentDetails +) { + public OrderId getOrderId() { + return OrderId.of(orderId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/MobilepayWebhookRegistrationRequest.java b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/MobilepayWebhookRegistrationRequest.java new file mode 100644 index 0000000..d84aa39 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/MobilepayWebhookRegistrationRequest.java @@ -0,0 +1,4 @@ +package com.zenfulcode.commercify.api.payment.dto.request; + +public record MobilepayWebhookRegistrationRequest(String callbackUrl) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/PaymentDetailsRequest.java b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/PaymentDetailsRequest.java new file mode 100644 index 0000000..a4b1ad0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/dto/request/PaymentDetailsRequest.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.api.payment.dto.request; + +import java.util.Map; + +public record PaymentDetailsRequest( + String paymentMethod, + String returnUrl, + String cancelUrl, + Map additionalData +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/CapturedPaymentResponse.java b/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/CapturedPaymentResponse.java new file mode 100644 index 0000000..9be8fa3 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/CapturedPaymentResponse.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.api.payment.dto.response; + +import com.zenfulcode.commercify.payment.application.dto.CaptureAmount; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; + +public record CapturedPaymentResponse( + TransactionId transactionId, + CaptureAmount captureAmount, + boolean isFullyCaptured +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/PaymentResponse.java b/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/PaymentResponse.java new file mode 100644 index 0000000..00123c5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/dto/response/PaymentResponse.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.api.payment.dto.response; + +import java.util.Map; + +public record PaymentResponse( + String paymentId, + String redirectUrl, + Map additionalData +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/payment/mapper/PaymentDtoMapper.java b/src/main/java/com/zenfulcode/commercify/api/payment/mapper/PaymentDtoMapper.java new file mode 100644 index 0000000..b8f537d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/payment/mapper/PaymentDtoMapper.java @@ -0,0 +1,85 @@ +package com.zenfulcode.commercify.api.payment.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zenfulcode.commercify.api.payment.dto.request.InitiatePaymentRequest; +import com.zenfulcode.commercify.api.payment.dto.request.PaymentDetailsRequest; +import com.zenfulcode.commercify.api.payment.dto.response.CapturedPaymentResponse; +import com.zenfulcode.commercify.api.payment.dto.response.PaymentResponse; +import com.zenfulcode.commercify.order.application.service.OrderApplicationService; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.application.command.CapturePaymentCommand; +import com.zenfulcode.commercify.payment.application.command.InitiatePaymentCommand; +import com.zenfulcode.commercify.payment.application.dto.CapturedPayment; +import com.zenfulcode.commercify.payment.application.dto.InitializedPayment; +import com.zenfulcode.commercify.payment.application.service.PaymentApplicationService; +import com.zenfulcode.commercify.payment.domain.exception.WebhookProcessingException; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.valueobject.MobilepayPaymentRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.WebhookRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.MobilepayWebhookPayload; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentDtoMapper { + private final PaymentApplicationService paymentService; + private final OrderApplicationService orderService; + private final ObjectMapper objectMapper; + + public InitiatePaymentCommand toCommand(InitiatePaymentRequest request) { + Order order = orderService.getOrderById(request.getOrderId()); + + return new InitiatePaymentCommand( + order, + PaymentMethod.valueOf(request.paymentDetails().paymentMethod()), + paymentService.getPaymentProvider(request.provider()), + toProviderRequest(request.paymentDetails()) + ); + } + + public PaymentResponse toResponse(InitializedPayment response) { + return new PaymentResponse( + response.paymentId().toString(), + response.redirectUrl(), + response.additionalData() + ); + } + + // TODO: Make more generic to support other providers + public WebhookPayload toWebhookPayload(WebhookRequest request) { + try { + return objectMapper.readValue(request.body(), MobilepayWebhookPayload.class); + } catch (JsonProcessingException e) { + throw new WebhookProcessingException(e.getMessage()); + } + } + + public CapturePaymentCommand toCaptureCommand(OrderId orderId) { + return new CapturePaymentCommand( + orderId, + null + ); + } + + private PaymentProviderRequest toProviderRequest(PaymentDetailsRequest details) { + return new MobilepayPaymentRequest( + PaymentMethod.valueOf(details.paymentMethod()), + details.additionalData().get("phoneNumber"), + details.returnUrl() + ); + } + + public CapturedPaymentResponse toCapturedResponse(CapturedPayment capturedPayment) { + return new CapturedPaymentResponse( + capturedPayment.transactionId(), + capturedPayment.captureAmount(), + capturedPayment.isFullyCaptured() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/ProductController.java b/src/main/java/com/zenfulcode/commercify/api/product/ProductController.java new file mode 100644 index 0000000..8bd44bd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/ProductController.java @@ -0,0 +1,166 @@ +package com.zenfulcode.commercify.api.product; + +import com.zenfulcode.commercify.api.product.dto.request.*; +import com.zenfulcode.commercify.api.product.dto.response.CreateProductResponse; +import com.zenfulcode.commercify.api.product.dto.response.PagedProductResponse; +import com.zenfulcode.commercify.api.product.dto.response.ProductDetailResponse; +import com.zenfulcode.commercify.api.product.dto.response.UpdateProductResponse; +import com.zenfulcode.commercify.api.product.mapper.ProductDtoMapper; +import com.zenfulcode.commercify.api.product.mapper.ProductResponseMapper; +import com.zenfulcode.commercify.product.application.command.*; +import com.zenfulcode.commercify.product.application.query.ProductQuery; +import com.zenfulcode.commercify.product.application.service.ProductApplicationService; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/v2/products") +@RequiredArgsConstructor +public class ProductController { + private final ProductApplicationService productApplicationService; + private final ProductDtoMapper dtoMapper; + private final ProductResponseMapper responseMapper; + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> createProduct( + @Validated @RequestBody CreateProductRequest request) { + + // Map request to command + CreateProductCommand command = dtoMapper.toCommand(request); + + // Execute use case + ProductId productId = productApplicationService.createProduct(command); + + // Return response + CreateProductResponse response = new CreateProductResponse( + productId.getId(), + "Product created successfully" + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/{productId}") + public ResponseEntity> getProduct( + @PathVariable String productId) { + Product product = productApplicationService.getProductById(ProductId.of(productId)); + ProductDetailResponse response = dtoMapper.toDetailResponse(product); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping + public ResponseEntity> getAllProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "DESC") String sortDirection, + @RequestParam(defaultValue = "true") boolean active) { + + Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); + + Page products = productApplicationService.findProducts( + active ? ProductQuery.active() : ProductQuery.all(), + pageRequest + ); + + PagedProductResponse response = responseMapper.toPagedResponse(products); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PutMapping("/{productId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> updateProduct( + @PathVariable String productId, + @RequestBody UpdateProductRequest request) { + + UpdateProductCommand command = dtoMapper.toCommand(ProductId.of(productId), request); + productApplicationService.updateProduct(command); + + UpdateProductResponse response = new UpdateProductResponse( + "Product updated successfully" + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @PostMapping("/{productId}/inventory") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> adjustInventory( + @PathVariable String productId, + @RequestBody AdjustInventoryRequest request) { + + AdjustInventoryCommand command = dtoMapper.toCommand(ProductId.of(productId), request); + productApplicationService.adjustInventory(command); + + return ResponseEntity.ok(ApiResponse.success("Inventory adjusted successfully")); + } + + @PostMapping("/{productId}/variants") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> addVariants( + @PathVariable String productId, + @RequestBody AddVariantsRequest request) { + + AddProductVariantsCommand command = dtoMapper.toCommand(ProductId.of(productId), request); + productApplicationService.addProductVariants(command); + + return ResponseEntity.ok(ApiResponse.success("Variants added successfully")); + } + + @PutMapping("/{productId}/variants/prices") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> updateVariantPrices( + @PathVariable String productId, + @RequestBody UpdateVariantPricesRequest request) { + + UpdateVariantPricesCommand command = dtoMapper.toCommand(ProductId.of(productId), request); + productApplicationService.updateVariantPrices(command); + + return ResponseEntity.ok(ApiResponse.success("Variant prices updated successfully")); + } + + @PostMapping("/{productId}/deactivate") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> deactivateProduct(@PathVariable String productId) { + DeactivateProductCommand command = new DeactivateProductCommand(ProductId.of(productId)); + log.info("Deactivating product with ID: {}", productId); + productApplicationService.deactivateProduct(command); + log.info("Product deactivated successfully"); + return ResponseEntity.ok(ApiResponse.success("Product deactivated successfully")); + } + + @PostMapping("/{productId}/activate") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> activateProduct(@PathVariable String productId) { + ActivateProductCommand command = new ActivateProductCommand(ProductId.of(productId)); + log.info("Activating product with ID: {}", productId); + productApplicationService.activateProduct(command); + + log.info("Product activated successfully"); + + return ResponseEntity.ok(ApiResponse.success("Product activated successfully")); + } + + @DeleteMapping("/{productId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> deleteProduct(@PathVariable String productId) { + DeleteProductCommand command = new DeleteProductCommand(ProductId.of(productId)); + productApplicationService.deleteProduct(command); + + return ResponseEntity.ok(ApiResponse.success("Product deleted successfully")); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AddVariantsRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AddVariantsRequest.java new file mode 100644 index 0000000..287bd74 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AddVariantsRequest.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import java.util.List; + +public record AddVariantsRequest( + List variants +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AdjustInventoryRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AdjustInventoryRequest.java new file mode 100644 index 0000000..c3ec0d1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/AdjustInventoryRequest.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +public record AdjustInventoryRequest( + String type, + Integer quantity, + String reason +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateProductRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateProductRequest.java new file mode 100644 index 0000000..90d7a03 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateProductRequest.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record CreateProductRequest( + String name, + String description, + String imageUrl, + Integer initialStock, + Money price, + List variants +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateVariantRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateVariantRequest.java new file mode 100644 index 0000000..79a7af9 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/CreateVariantRequest.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record CreateVariantRequest( + Integer stock, + Money price, + String imageUrl, + List options +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateProductRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateProductRequest.java new file mode 100644 index 0000000..df32707 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateProductRequest.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record UpdateProductRequest( + String name, + String description, + Integer stock, + Money price, + Boolean active +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateVariantPricesRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateVariantPricesRequest.java new file mode 100644 index 0000000..bc072f2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/UpdateVariantPricesRequest.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import java.util.List; + +public record UpdateVariantPricesRequest( + List updates +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantOptionRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantOptionRequest.java new file mode 100644 index 0000000..dd1f375 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantOptionRequest.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +public record VariantOptionRequest( + String name, + String value +) {} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantPriceUpdateRequest.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantPriceUpdateRequest.java new file mode 100644 index 0000000..2c6de5e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/request/VariantPriceUpdateRequest.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.product.dto.request; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record VariantPriceUpdateRequest( + String sku, + Money price +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/CreateProductResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/CreateProductResponse.java new file mode 100644 index 0000000..bff9ea0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/CreateProductResponse.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; + +public record CreateProductResponse(String productId, String message) { + public ProductId getProductId() { + return ProductId.of(productId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PageInfo.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PageInfo.java new file mode 100644 index 0000000..f58ea3b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PageInfo.java @@ -0,0 +1,25 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +public record PageInfo( + int pageNumber, + int pageSize, + long totalElements, + int totalPages, + boolean isFirst, + boolean isLast, + boolean hasNext, + boolean hasPrevious +) { + public PageInfo(int pageNumber, int pageSize, long totalElements, int totalPages) { + this( + pageNumber, + pageSize, + totalElements, + totalPages, + pageNumber == 0, + pageNumber == totalPages - 1, + pageNumber < totalPages - 1, + pageNumber > 0 + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PagedProductResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PagedProductResponse.java new file mode 100644 index 0000000..c32c8d8 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/PagedProductResponse.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +import java.util.List; + +public record PagedProductResponse( + List items, + PageInfo pageInfo +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductDetailResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductDetailResponse.java new file mode 100644 index 0000000..d0e9c47 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductDetailResponse.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record ProductDetailResponse( + String id, + String name, + String description, + String imageUrl, + int stock, + Money price, + boolean active, + List variants +) { + +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductSummaryResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductSummaryResponse.java new file mode 100644 index 0000000..9f5ed59 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductSummaryResponse.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record ProductSummaryResponse( + String id, + String name, + String description, + String imageUrl, + Money price, + int stock +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductVariantSummaryResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductVariantSummaryResponse.java new file mode 100644 index 0000000..fbc362d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/ProductVariantSummaryResponse.java @@ -0,0 +1,19 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record ProductVariantSummaryResponse( + String id, + String sku, + List options, + Money price, + int stock +) { + public record VariantOptionResponse( + String name, + String value + ) { + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/dto/response/UpdateProductResponse.java b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/UpdateProductResponse.java new file mode 100644 index 0000000..67d6f86 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/dto/response/UpdateProductResponse.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.api.product.dto.response; + +public record UpdateProductResponse( + String message +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductDtoMapper.java b/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductDtoMapper.java new file mode 100644 index 0000000..79abe53 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductDtoMapper.java @@ -0,0 +1,117 @@ +package com.zenfulcode.commercify.api.product.mapper; + +import com.zenfulcode.commercify.api.product.dto.request.*; +import com.zenfulcode.commercify.api.product.dto.response.ProductDetailResponse; +import com.zenfulcode.commercify.api.product.dto.response.ProductVariantSummaryResponse; +import com.zenfulcode.commercify.product.application.command.*; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.*; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class ProductDtoMapper { + + public CreateProductCommand toCommand(CreateProductRequest request) { + return new CreateProductCommand( + request.name(), + request.description(), + request.imageUrl(), + request.initialStock(), + request.price(), + mapVariantSpecs(request.variants()) + ); + } + + public AddProductVariantsCommand toCommand(ProductId productId, AddVariantsRequest request) { + List specs = request.variants().stream() + .map(this::toVariantSpec) + .collect(Collectors.toList()); + + return new AddProductVariantsCommand(productId, specs); + } + + public AdjustInventoryCommand toCommand(ProductId productId, AdjustInventoryRequest request) { + return new AdjustInventoryCommand( + productId, + InventoryAdjustmentType.valueOf(request.type()), + request.quantity(), + request.reason() + ); + } + + public UpdateProductCommand toCommand(ProductId productId, UpdateProductRequest request) { + ProductUpdateSpec updateSpec = new ProductUpdateSpec( + request.name(), + request.description(), + request.stock(), + request.price(), + request.active() + ); + return new UpdateProductCommand(productId, updateSpec); + } + + public UpdateVariantPricesCommand toCommand(ProductId productId, UpdateVariantPricesRequest request) { + List updates = request.updates().stream() + .map(update -> new VariantPriceUpdate( + update.sku(), + update.price() + )) + .collect(Collectors.toList()); + + return new UpdateVariantPricesCommand(productId, updates); + } + + private List mapVariantSpecs(List variants) { + if (variants == null) return List.of(); + + return variants.stream() + .map(this::toVariantSpec) + .collect(Collectors.toList()); + } + + private VariantSpecification toVariantSpec(CreateVariantRequest request) { + return new VariantSpecification( + request.stock(), + request.price() != null ? request.price() : null, + request.imageUrl(), + request.options().stream() + .map(opt -> new VariantOption(opt.name(), opt.value())) + .collect(Collectors.toList()) + ); + } + + private List mapVariants(Set variants) { + return variants.stream() + .map(variant -> new ProductVariantSummaryResponse( + variant.getId().toString(), + variant.getSku(), + variant.getVariantOptions().stream() + .map(opt -> new ProductVariantSummaryResponse.VariantOptionResponse( + opt.getName(), + opt.getValue() + )) + .collect(Collectors.toList()), + variant.getPrice(), + variant.getStock() + )) + .collect(Collectors.toList()); + } + + public ProductDetailResponse toDetailResponse(Product product) { + return new ProductDetailResponse( + product.getId().getId(), + product.getName(), + product.getDescription(), + product.getImageUrl(), + product.getStock(), + product.getPrice(), + product.isActive(), + mapVariants(product.getProductVariants()) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductResponseMapper.java b/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductResponseMapper.java new file mode 100644 index 0000000..e4dd6a1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/product/mapper/ProductResponseMapper.java @@ -0,0 +1,73 @@ +package com.zenfulcode.commercify.api.product.mapper; + +import com.zenfulcode.commercify.api.product.dto.response.PageInfo; +import com.zenfulcode.commercify.api.product.dto.response.PagedProductResponse; +import com.zenfulcode.commercify.api.product.dto.response.ProductSummaryResponse; +import com.zenfulcode.commercify.api.product.dto.response.ProductVariantSummaryResponse; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.model.VariantOption; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Component +public class ProductResponseMapper { + public PagedProductResponse toPagedResponse(Page productPage) { + List items = productPage.getContent() + .stream() + .map(this::toSummaryResponse) + .toList(); + + PageInfo pageInfo = new PageInfo( + productPage.getNumber(), + productPage.getSize(), + productPage.getTotalElements(), + productPage.getTotalPages() + ); + + return new PagedProductResponse( + items, + pageInfo + ); + } + + private ProductSummaryResponse toSummaryResponse(Product product) { + return new ProductSummaryResponse( + product.getId().toString(), + product.getName(), + product.getDescription(), + product.getImageUrl(), + product.getPrice(), + product.getStock() + ); + } + + private List toVariantResponses(Set variants) { + return variants.stream() + .map(this::toVariantResponse) + .toList(); + } + + private ProductVariantSummaryResponse toVariantResponse(ProductVariant variant) { + return new ProductVariantSummaryResponse( + variant.getId().toString(), + variant.getSku(), + toVariantOptionResponses(variant.getVariantOptions()), + variant.getPrice(), + variant.getStock() != null ? variant.getStock() : variant.getProduct().getStock() + ); + } + + private List toVariantOptionResponses( + Set options) { + return options.stream() + .map(option -> new ProductVariantSummaryResponse.VariantOptionResponse( + option.getName(), + option.getValue() + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/api/system/MetricsController.java b/src/main/java/com/zenfulcode/commercify/api/system/MetricsController.java new file mode 100644 index 0000000..cf79d91 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/system/MetricsController.java @@ -0,0 +1,61 @@ +package com.zenfulcode.commercify.api.system; + +import com.zenfulcode.commercify.api.system.dto.MetricsRequest; +import com.zenfulcode.commercify.api.system.dto.MetricsResponse; +import com.zenfulcode.commercify.metrics.application.MetricsApplicationService; +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Slf4j +@RestController +@RequestMapping("/api/v2/metrics") +@RequiredArgsConstructor +public class MetricsController { + + private final MetricsApplicationService metricsService; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getMetrics( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Integer lastDays) { + + log.info("Getting metrics"); + + MetricsRequest request = new MetricsRequest(startDate, endDate, lastDays); + + // Handle either date range or "last X days" + if (lastDays != null) { + request.setLastDays(lastDays); + } else { + request.setStartDate(startDate); + request.setEndDate(endDate); + } + + final MetricsQuery metricsQuery = MetricsQuery.of(request); + + MetricsResponse metrics = metricsService.getMetrics(metricsQuery); + return ResponseEntity.ok(ApiResponse.success(metrics)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getMetricsWithFilters( + @Validated @RequestBody MetricsRequest request) { + + final MetricsQuery metricsQuery = MetricsQuery.of(request); + + MetricsResponse metrics = metricsService.getMetrics(metricsQuery); + return ResponseEntity.ok(ApiResponse.success(metrics)); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsRequest.java b/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsRequest.java new file mode 100644 index 0000000..224db29 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsRequest.java @@ -0,0 +1,42 @@ +package com.zenfulcode.commercify.api.system.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.Min; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Data +public class MetricsRequest { + private LocalDate startDate; + private LocalDate endDate; + + @Min(1) + private Integer lastDays; + + public MetricsRequest(@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Integer lastDays) { + this.startDate = startDate; + this.endDate = endDate; + this.lastDays = lastDays; + } + + // Optional filters + private String productCategory; + private String region; + + @JsonIgnore + public LocalDate getEffectiveStartDate() { + if (lastDays != null) { + return LocalDate.now().minusDays(lastDays); + } + return startDate != null ? startDate : LocalDate.now().minusDays(30); // Default 30 days + } + + @JsonIgnore + public LocalDate getEffectiveEndDate() { + return endDate != null ? endDate : LocalDate.now(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsResponse.java b/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsResponse.java new file mode 100644 index 0000000..9af50d1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/api/system/dto/MetricsResponse.java @@ -0,0 +1,31 @@ +package com.zenfulcode.commercify.api.system.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MetricsResponse { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private LocalDate startDate; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private LocalDate endDate; + + private BigDecimal totalRevenue; + private int totalOrders; + private int newProductsAdded; + private int activeUsers; + + // Optional: include trend data + private BigDecimal revenueChangePercent; + private int orderChangePercent; +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/application/command/LoginCommand.java b/src/main/java/com/zenfulcode/commercify/auth/application/command/LoginCommand.java new file mode 100644 index 0000000..a8c48ab --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/application/command/LoginCommand.java @@ -0,0 +1,4 @@ +package com.zenfulcode.commercify.auth.application.command; + +public record LoginCommand(String email, String password, boolean isGuest) { +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationApplicationService.java b/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationApplicationService.java new file mode 100644 index 0000000..83bd6c1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationApplicationService.java @@ -0,0 +1,105 @@ +package com.zenfulcode.commercify.auth.application.service; + +import com.zenfulcode.commercify.auth.application.command.LoginCommand; +import com.zenfulcode.commercify.auth.domain.event.UserAuthenticatedEvent; +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.auth.domain.service.AuthenticationDomainService; +import com.zenfulcode.commercify.auth.infrastructure.security.TokenService; +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import com.zenfulcode.commercify.user.application.command.CreateUserCommand; +import com.zenfulcode.commercify.user.application.service.UserApplicationService; +import com.zenfulcode.commercify.user.domain.exception.UserAlreadyExistsException; +import com.zenfulcode.commercify.user.domain.exception.UserNotFoundException; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserRole; +import com.zenfulcode.commercify.user.domain.repository.UserRepository; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Slf4j +@Service +@AllArgsConstructor +public class AuthenticationApplicationService { + private final AuthenticationManager authenticationManager; + private final AuthenticationDomainService authenticationDomainService; + private final UserRepository userRepository; + private final TokenService tokenService; + private final DomainEventPublisher eventPublisher; + private final UserApplicationService userApplicationService; + + @Transactional + public AuthenticationResult authenticate(LoginCommand command) { + String email = command.email(); + String password = command.password(); + + UserId userId; + if (!command.isGuest()) { + User user = userRepository.findByEmail(command.email()).orElseThrow(() -> new UserNotFoundException(command.email())); + + userId = user.getId(); + } else { + Optional user = userRepository.findByEmail(command.email()); + if (user.isPresent()) { + throw new UserAlreadyExistsException("There is already a user with this email"); + } + + email = "guest-" + System.currentTimeMillis() + "@commercify.com"; + password = UUID.randomUUID().toString(); + + CreateUserCommand createUserCommand = new CreateUserCommand(email, "Guest", "User", password, Set.of(UserRole.GUEST), null); + + userId = userApplicationService.createUser(createUserCommand); + } + + Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(email, password)); + AuthenticatedUser authenticatedUser = (AuthenticatedUser) authentication.getPrincipal(); + + // Generate tokens + String accessToken = tokenService.generateAccessToken(authenticatedUser); + String refreshToken = tokenService.generateRefreshToken(authenticatedUser); + + // Publish domain event + eventPublisher.publish(new UserAuthenticatedEvent(this, userId, email, command.isGuest())); + + return new AuthenticationResult(accessToken, refreshToken, authenticatedUser); + } + + @Transactional(readOnly = true) + public AuthenticatedUser validateAccessToken(String token) { + String userId = tokenService.validateTokenAndGetUserId(token); + log.info("Validating access token for user: '{}'", userId); + + User user = userRepository.findById(UserId.of(userId)).orElseThrow(); + return authenticationDomainService.createAuthenticatedUser(user); + } + + @Transactional + public AuthenticationResult refreshToken(String refreshToken) { + String userId = tokenService.validateTokenAndGetUserId(refreshToken); + User user = userRepository.findById(UserId.of(userId)).orElseThrow(); + + AuthenticatedUser authenticatedUser = authenticationDomainService.createAuthenticatedUser(user); + + String newAccessToken = tokenService.generateAccessToken(authenticatedUser); + String newRefreshToken = tokenService.generateRefreshToken(authenticatedUser); + + return new AuthenticationResult(newAccessToken, newRefreshToken, authenticatedUser); + } + + public Optional extractTokenFromHeader(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return Optional.empty(); + } + return Optional.of(authHeader.substring(7)); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationResult.java b/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationResult.java new file mode 100644 index 0000000..d7dc84f --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/application/service/AuthenticationResult.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.auth.application.service; + +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; + +public record AuthenticationResult( + String accessToken, + String refreshToken, + AuthenticatedUser user +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/event/UserAuthenticatedEvent.java b/src/main/java/com/zenfulcode/commercify/auth/domain/event/UserAuthenticatedEvent.java new file mode 100644 index 0000000..cfce0e8 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/event/UserAuthenticatedEvent.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.auth.domain.event; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +@Getter +public class UserAuthenticatedEvent extends DomainEvent { + @AggregateId + private final UserId userId; + private final String username; + private final boolean isGuest; + + public UserAuthenticatedEvent(Object source, UserId userId, String username, boolean isGuest) { + super(source); + this.userId = userId; + this.username = username; + this.isGuest = isGuest; + } + + @Override + public String getEventType() { + return isGuest ? "GUEST_AUTHENTICATED" : "USER_AUTHENTICATED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidAuthenticationException.java b/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidAuthenticationException.java new file mode 100644 index 0000000..686e6ec --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidAuthenticationException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.auth.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class InvalidAuthenticationException extends DomainException { + public InvalidAuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidCredentialsException.java b/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..d8b6cf7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/exception/InvalidCredentialsException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.auth.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class InvalidCredentialsException extends DomainException { + public InvalidCredentialsException() { + super("Invalid username or password"); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/model/AuthenticatedUser.java b/src/main/java/com/zenfulcode/commercify/auth/domain/model/AuthenticatedUser.java new file mode 100644 index 0000000..8241234 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/model/AuthenticatedUser.java @@ -0,0 +1,120 @@ +package com.zenfulcode.commercify.auth.domain.model; + +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuthenticatedUser implements UserDetails { + private String userId; + private String username; + private String email; + private String password; + private Set roles; + private boolean enabled; + private boolean accountNonExpired; + private boolean accountNonLocked; + private boolean credentialsNonExpired; + + private AuthenticatedUser( + String userId, + String username, + String email, + String password, + Set roles, + boolean enabled, + boolean accountNonExpired, + boolean accountNonLocked, + boolean credentialsNonExpired + ) { + this.userId = userId; + this.username = username; + this.email = email; + this.password = password; + this.roles = roles; + this.enabled = enabled; + this.accountNonExpired = accountNonExpired; + this.accountNonLocked = accountNonLocked; + this.credentialsNonExpired = credentialsNonExpired; + } + + public static AuthenticatedUser create( + String userId, + String username, + String email, + String password, + Set roles, + boolean enabled, + boolean accountNonExpired, + boolean accountNonLocked, + boolean credentialsNonExpired + ) { + return new AuthenticatedUser( + userId, + username, + email, + password, + roles, + enabled, + accountNonExpired, + accountNonLocked, + credentialsNonExpired + ); + } + + public UserId getUserId() { + return UserId.of(userId); + } + + @Override + public Collection getAuthorities() { + return roles.stream() + .map(role -> new SimpleGrantedAuthority(role.name())) + .collect(Collectors.toList()); + } + + public boolean isAdmin() { + return roles.contains(UserRole.ROLE_ADMIN); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/model/UserRole.java b/src/main/java/com/zenfulcode/commercify/auth/domain/model/UserRole.java new file mode 100644 index 0000000..b4ec167 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/model/UserRole.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.auth.domain.model; + +public enum UserRole { + ROLE_GUEST, + ROLE_USER, + ROLE_ADMIN, + ROLE_MANAGER +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/auth/domain/service/AuthenticationDomainService.java b/src/main/java/com/zenfulcode/commercify/auth/domain/service/AuthenticationDomainService.java new file mode 100644 index 0000000..cd8baa7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/domain/service/AuthenticationDomainService.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.auth.domain.service; + +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; + +public interface AuthenticationDomainService { + AuthenticatedUser createAuthenticatedUser(User user); + + void validateUserStatus(UserStatus status); +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/config/SecurityConfig.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/config/SecurityConfig.java new file mode 100644 index 0000000..3ef9e69 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/config/SecurityConfig.java @@ -0,0 +1,101 @@ +package com.zenfulcode.commercify.auth.infrastructure.config; + +import com.zenfulcode.commercify.auth.application.service.AuthenticationApplicationService; +import com.zenfulcode.commercify.auth.infrastructure.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final UserDetailsService userDetailsService; + + @Value("${frontend.host}") + private String frontendHost; + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter( + AuthenticationApplicationService authService) { + return new JwtAuthenticationFilter(authService); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(req -> req + .requestMatchers( + "/api/v2/auth/**", // This should cover your NextAuth endpoints + "/api/v2/auth/nextauth", // Add explicitly + "/api/v2/auth/session", + "/api/v2/products", + "/api/v2/products/{productId}", + "/api/v2/payments/webhooks/{provider}/callback").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .cors(config -> config.configurationSource(corsConfigurationSource())); + + return http.build(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(15); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(frontendHost, "http://localhost:3000", "http://localhost:5170")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(List.of("Authorization")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/mapper/UserRoleMapper.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/mapper/UserRoleMapper.java new file mode 100644 index 0000000..ab5745d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/mapper/UserRoleMapper.java @@ -0,0 +1,17 @@ +package com.zenfulcode.commercify.auth.infrastructure.mapper; + +import com.zenfulcode.commercify.auth.domain.model.UserRole; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class UserRoleMapper { + public Set mapRoles(Collection roles) { + return roles.stream() + .map(role -> UserRole.valueOf("ROLE_" + role.name())) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/CustomUserDetailsService.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/CustomUserDetailsService.java new file mode 100644 index 0000000..72b236c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/CustomUserDetailsService.java @@ -0,0 +1,32 @@ +package com.zenfulcode.commercify.auth.infrastructure.security; + +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.auth.infrastructure.mapper.UserRoleMapper; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + private final UserRoleMapper userRoleMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + username)); + + return AuthenticatedUser.builder() + .userId(user.getId().toString()) + .email(user.getEmail()) + .username(user.getFullName()) + .password(user.getPassword()) + .roles(userRoleMapper.mapRoles(user.getRoles())) + .build(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/JwtAuthenticationFilter.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..12b5cbc --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.zenfulcode.commercify.auth.infrastructure.security; + +import com.zenfulcode.commercify.auth.application.service.AuthenticationApplicationService; +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final AuthenticationApplicationService authService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + try { + String token = extractJwtToken(request); + + if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { + AuthenticatedUser user = authService.validateAccessToken(token); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.error("Could not set user authentication in security context", e); + } + + filterChain.doFilter(request, response); + } + + private String extractJwtToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/TokenService.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/TokenService.java new file mode 100644 index 0000000..e59c0e9 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/security/TokenService.java @@ -0,0 +1,63 @@ +package com.zenfulcode.commercify.auth.infrastructure.security; + +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Service +public class TokenService { + + @Value("${security.jwt.secret}") + private String jwtSecret; + + @Value("${security.jwt.access-token-expiration}") + private long accessTokenExpiration; + + @Value("${security.jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + public String generateAccessToken(AuthenticatedUser user) { + return buildToken(user, accessTokenExpiration); + } + + public String generateRefreshToken(AuthenticatedUser user) { + return buildToken(user, refreshTokenExpiration); + } + + public String validateTokenAndGetUserId(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + private String buildToken(AuthenticatedUser user, long expiration) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .subject(user.getUserId().toString()) + .claim("username", user.getUsername()) + .claim("email", user.getEmail()) + .claim("roles", user.getRoles()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/auth/infrastructure/service/DefaultAuthenticationDomainService.java b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/service/DefaultAuthenticationDomainService.java new file mode 100644 index 0000000..3218012 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/auth/infrastructure/service/DefaultAuthenticationDomainService.java @@ -0,0 +1,43 @@ +package com.zenfulcode.commercify.auth.infrastructure.service; + +import com.zenfulcode.commercify.auth.domain.model.AuthenticatedUser; +import com.zenfulcode.commercify.auth.domain.service.AuthenticationDomainService; +import com.zenfulcode.commercify.auth.infrastructure.mapper.UserRoleMapper; +import com.zenfulcode.commercify.shared.domain.exception.UserAccountLockedException; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DefaultAuthenticationDomainService implements AuthenticationDomainService { + private final UserRoleMapper roleMapper; + + @Override + public AuthenticatedUser createAuthenticatedUser(User user) { + validateUserStatus(user.getStatus()); + + return AuthenticatedUser.create( + user.getId().toString(), + user.getFullName(), + user.getEmail(), + user.getPassword(), + roleMapper.mapRoles(user.getRoles()), + user.getStatus() == UserStatus.ACTIVE, + user.getStatus() != UserStatus.DEACTIVATED, + user.getStatus() != UserStatus.SUSPENDED, + true + ); + } + + @Override + public void validateUserStatus(UserStatus status) { + if (status == UserStatus.SUSPENDED) { + throw new UserAccountLockedException("Account is suspended"); + } + if (status == UserStatus.DEACTIVATED) { + throw new UserAccountLockedException("Account is deactivated"); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/OrderStatus.java b/src/main/java/com/zenfulcode/commercify/commercify/OrderStatus.java deleted file mode 100644 index 0a72a64..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/OrderStatus.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify; - -public enum OrderStatus { - PENDING, // Order has been created but not yet confirmed - PAID, // Order has been confirmed by the customer - SHIPPED, // Order has been shipped - COMPLETED, // Order has been delivered - CANCELLED, // Order has been cancelled - FAILED, // Order has failed - REFUNDED, // Order has been refunded - RETURNED // Order has been returned -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/PaymentProvider.java b/src/main/java/com/zenfulcode/commercify/commercify/PaymentProvider.java deleted file mode 100644 index 5498baf..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/PaymentProvider.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.zenfulcode.commercify.commercify; - -public enum PaymentProvider { - STRIPE, MOBILEPAY -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/PaymentStatus.java b/src/main/java/com/zenfulcode/commercify/commercify/PaymentStatus.java deleted file mode 100644 index dc368ae..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/PaymentStatus.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify; - -public enum PaymentStatus { - PENDING, - PAID, - FAILED, - CANCELLED, - REFUNDED, - NOT_FOUND, - TERMINATED, - CAPTURED, EXPIRED -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/LoginUserRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/LoginUserRequest.java deleted file mode 100644 index 7e7d6fd..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/LoginUserRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests; - -public record LoginUserRequest(String email, String password) { -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/PaymentRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/PaymentRequest.java deleted file mode 100644 index 3459997..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/PaymentRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests; - -public record PaymentRequest(Long orderId, - String currency, - String paymentMethod, - String returnUrl, - String phoneNumber) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/RegisterUserRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/RegisterUserRequest.java deleted file mode 100644 index 2687b78..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/RegisterUserRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests; - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; - -public record RegisterUserRequest( - String email, - String password, - String firstName, - String lastName, - AddressDTO defaultAddress) { - public UserDTO toUserDTO() { - return new UserDTO(null, email, firstName, lastName, null, defaultAddress, null); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderLineRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderLineRequest.java deleted file mode 100644 index f2a4c6b..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderLineRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.orders; - -public record CreateOrderLineRequest( - Long productId, - Long variantId, - Integer quantity -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderRequest.java deleted file mode 100644 index 73a8a95..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/CreateOrderRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.orders; - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.CustomerDetailsDTO; - -import java.util.List; - -public record CreateOrderRequest( - String currency, - CustomerDetailsDTO customerDetails, - List orderLines, - Double shippingCost, - AddressDTO shippingAddress, - AddressDTO billingAddress -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/OrderStatusUpdateRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/OrderStatusUpdateRequest.java deleted file mode 100644 index df6b590..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/orders/OrderStatusUpdateRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.orders; - -public record OrderStatusUpdateRequest(String status) { -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/CreateVariantOptionRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/CreateVariantOptionRequest.java deleted file mode 100644 index 08282ff..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/CreateVariantOptionRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.products; - -public record CreateVariantOptionRequest( - String name, - String value -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/PriceRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/PriceRequest.java deleted file mode 100644 index 096857d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/PriceRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.products; - -public record PriceRequest( - String currency, - Double amount -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductRequest.java deleted file mode 100644 index fcd4575..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.products; - -import java.util.List; - -public record ProductRequest( - String name, - String description, - Integer stock, - String imageUrl, - Boolean active, - PriceRequest price, - List variants -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductVariantRequest.java b/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductVariantRequest.java deleted file mode 100644 index 940973d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/requests/products/ProductVariantRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.requests.products; - -import java.util.List; - -public record ProductVariantRequest( - String sku, - Integer stock, - String imageUrl, - Double unitPrice, - List options -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/AuthResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/AuthResponse.java deleted file mode 100644 index af37219..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/AuthResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - - -import com.zenfulcode.commercify.commercify.dto.UserDTO; - -public record AuthResponse(UserDTO user, String token, long expiresIn, String message) { - public static AuthResponse UserAuthenticated(UserDTO user, String token, long expiresIn) { - return new AuthResponse(user, token, expiresIn, "User authenticated"); - } - - public static AuthResponse AuthenticationFailed(String message) { - return new AuthResponse(null, null, 0, message); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/CancelPaymentResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/CancelPaymentResponse.java deleted file mode 100644 index 1234805..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/CancelPaymentResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - -public record CancelPaymentResponse(boolean success, String message) { - - public static CancelPaymentResponse PaymentNotFound() { - return new CancelPaymentResponse(false, "Payment not found"); - } - - public static CancelPaymentResponse PaymentAlreadyPaid() { - return new CancelPaymentResponse(false, "Payment already paid"); - } - - public static CancelPaymentResponse PaymentAlreadyCanceled() { - return new CancelPaymentResponse(false, "Payment already canceled"); - } - - public static CancelPaymentResponse InvalidPaymentProvider() { - return new CancelPaymentResponse(false, "Invalid payment provider"); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ErrorResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ErrorResponse.java deleted file mode 100644 index 4a508f9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ErrorResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class ErrorResponse { - private String message; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/PaymentResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/PaymentResponse.java deleted file mode 100644 index 660505e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/PaymentResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - - -import com.zenfulcode.commercify.commercify.PaymentStatus; - -public record PaymentResponse(Long paymentId, PaymentStatus status, String redirectUrl) { - public static PaymentResponse FailedPayment() { - return new PaymentResponse(-1L, PaymentStatus.FAILED, ""); - } -} - diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/RegisterUserResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/RegisterUserResponse.java deleted file mode 100644 index 9f07d8f..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/RegisterUserResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - - -import com.zenfulcode.commercify.commercify.dto.UserDTO; - -public record RegisterUserResponse(UserDTO user, String message) { - public static RegisterUserResponse RegistrationFailed(String message) { - return new RegisterUserResponse(null, message); - } - - public static RegisterUserResponse UserRegistered(UserDTO user) { - return new RegisterUserResponse(user, "User registered successfully"); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ValidationErrorResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ValidationErrorResponse.java deleted file mode 100644 index ce2bccd..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/ValidationErrorResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses; - -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.util.List; - -@Data -@AllArgsConstructor -public class ValidationErrorResponse { - private List errors; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/CreateOrderResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/CreateOrderResponse.java deleted file mode 100644 index 8b95f7c..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/CreateOrderResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses.orders; - - -import com.zenfulcode.commercify.commercify.viewmodel.OrderViewModel; - -public record CreateOrderResponse( - OrderViewModel order, - String message) { - public static CreateOrderResponse from(OrderViewModel order) { - return new CreateOrderResponse(order, "Order created successfully"); - } - - public static CreateOrderResponse from(String message) { - return new CreateOrderResponse(null, message); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/GetOrderResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/GetOrderResponse.java deleted file mode 100644 index 13a77b7..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/orders/GetOrderResponse.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses.orders; - - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.viewmodel.OrderLineViewModel; - -import java.time.Instant; -import java.util.List; - -public record GetOrderResponse( - Long id, - Long userId, - String customerName, - String customerEmail, - AddressDTO shippingAddress, - OrderStatus orderStatus, - String currency, - Double subTotal, - Double shippingCost, - Instant createdAt, - Instant updatedAt, - List orderLines -) { - public static GetOrderResponse from(OrderDetailsDTO orderDetails) { - OrderDTO order = orderDetails.getOrder(); - return new GetOrderResponse( - order.getId(), - order.getUserId(), - orderDetails.getCustomerDetails().getFirstName() + " " + orderDetails.getCustomerDetails().getLastName(), - orderDetails.getCustomerDetails().getEmail(), - orderDetails.getShippingAddress(), - order.getOrderStatus(), - order.getCurrency(), - order.getSubTotal(), - order.getShippingCost(), - order.getCreatedAt(), - order.getUpdatedAt(), - orderDetails.getOrderLines().stream() - .map(OrderLineViewModel::fromDTO) - .toList() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/JwtErrorResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/JwtErrorResponse.java deleted file mode 100644 index e6a487e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/JwtErrorResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses.products; - -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Data; - -import java.time.LocalDateTime; - -@Data -public class JwtErrorResponse { - private int status; - private String message; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime timestamp; - private String error; - - public JwtErrorResponse(String message, String error) { - this.status = 401; - this.message = message; - this.error = error; - this.timestamp = LocalDateTime.now(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductDeletionErrorResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductDeletionErrorResponse.java deleted file mode 100644 index 2358325..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductDeletionErrorResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses.products; - -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.util.List; - -@Data -@AllArgsConstructor -public class ProductDeletionErrorResponse { - private String message; - private List issues; - private List activeOrders; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductUpdateResponse.java b/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductUpdateResponse.java deleted file mode 100644 index b2f10be..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/api/responses/products/ProductUpdateResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify.api.responses.products; - -import com.zenfulcode.commercify.commercify.viewmodel.ProductViewModel; - -import java.util.List; - -public record ProductUpdateResponse( - ProductViewModel product, - String message, - List warnings -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/component/AdminDataLoader.java b/src/main/java/com/zenfulcode/commercify/commercify/component/AdminDataLoader.java deleted file mode 100644 index 75ad3f5..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/component/AdminDataLoader.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.zenfulcode.commercify.commercify.component; - -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class AdminDataLoader { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @Value("${admin.email}") - private String email; - @Value("${admin.password}") - private String password; - - @Bean - public CommandLineRunner loadData() { - return args -> { - if (userRepository.findByEmail(email).isEmpty()) { - AddressEntity defaultAddress = AddressEntity.builder() - .street("123 Main St") - .city("Springfield") - .state("IL") - .zipCode("62701") - .country("US") - .build(); - - UserEntity adminUser = UserEntity.builder() - .email(email) - .password(passwordEncoder.encode(password)) - .firstName("Admin") - .lastName("User") - .roles(List.of("ADMIN", "USER")) - .defaultAddress(defaultAddress) - .emailConfirmed(true) - .build(); - - userRepository.save(adminUser); - System.out.println("Admin user created successfully."); - } else { - System.out.println("Admin user already exists. Skipping creation."); - } - }; - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/config/ApplicationConfiguration.java b/src/main/java/com/zenfulcode/commercify/commercify/config/ApplicationConfiguration.java deleted file mode 100644 index b4b61a3..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/config/ApplicationConfiguration.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.zenfulcode.commercify.commercify.config; - - -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import lombok.AllArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.web.client.RestTemplate; - -@Configuration -@AllArgsConstructor -public class ApplicationConfiguration { - private final UserRepository userRepository; - - @Bean - UserDetailsService userDetailsService() { - return username -> userRepository.findByEmail(username) - .orElseThrow(() -> new BadCredentialsException("User not found")); - } - - @Bean - BCryptPasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { - return config.getAuthenticationManager(); - } - - @Bean - AuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - - authProvider.setUserDetailsService(userDetailsService()); - authProvider.setPasswordEncoder(passwordEncoder()); - - return authProvider; - } - - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} - diff --git a/src/main/java/com/zenfulcode/commercify/commercify/config/JwtAuthenticationFilter.java b/src/main/java/com/zenfulcode/commercify/commercify/config/JwtAuthenticationFilter.java deleted file mode 100644 index 68649c8..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/config/JwtAuthenticationFilter.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.zenfulcode.commercify.commercify.config; - - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.zenfulcode.commercify.commercify.api.responses.products.JwtErrorResponse; -import com.zenfulcode.commercify.commercify.service.JwtService; -import io.jsonwebtoken.ExpiredJwtException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import lombok.NonNull; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -@AllArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtService jwtService; - private final UserDetailsService userDetailsService; - private final ObjectMapper objectMapper; - - @Override - protected void doFilterInternal( - @NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { - final String authHeader = request.getHeader("Authorization"); - - try { - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - - final String jwt = authHeader.substring(7); - final String userEmail = jwtService.extractUsername(jwt); - - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (userEmail != null && authentication == null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - - if (jwtService.isTokenValid(jwt, userDetails)) { - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } - - filterChain.doFilter(request, response); - } catch (ExpiredJwtException e) { - handleExpiredJwtException(response, e); - } catch (Exception e) { - handleGenericJwtException(response, e); - } - } - - private void handleExpiredJwtException(HttpServletResponse response, ExpiredJwtException e) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - - JwtErrorResponse errorResponse = new JwtErrorResponse( - "JWT token has expired", - "Token Expired" - ); - - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } - - private void handleGenericJwtException(HttpServletResponse response, Exception e) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - - JwtErrorResponse errorResponse = new JwtErrorResponse( - "Invalid JWT token", - e.getMessage() - ); - - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/config/SecurityConfig.java b/src/main/java/com/zenfulcode/commercify/commercify/config/SecurityConfig.java deleted file mode 100644 index 01c9a7c..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/config/SecurityConfig.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.zenfulcode.commercify.commercify.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.retry.annotation.EnableRetry; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; - -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -@RequiredArgsConstructor -@EnableRetry -public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthFilter; - private final AuthenticationProvider authenticationProvider; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(req -> req - .requestMatchers( - "/api/v1/auth/**", - "/api/v1/products/active", - "/api/v1/products/{id}", - "/api/v1/payments/mobilepay/callback").permitAll() - .anyRequest().authenticated() - ) - .sessionManagement(smc -> smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authenticationProvider(authenticationProvider) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("*")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); - configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/controller/AuthenticationController.java b/src/main/java/com/zenfulcode/commercify/commercify/controller/AuthenticationController.java deleted file mode 100644 index c0af6ac..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/controller/AuthenticationController.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.zenfulcode.commercify.commercify.controller; - - -import com.zenfulcode.commercify.commercify.api.requests.LoginUserRequest; -import com.zenfulcode.commercify.commercify.api.requests.RegisterUserRequest; -import com.zenfulcode.commercify.commercify.api.responses.AuthResponse; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.service.AuthenticationService; -import com.zenfulcode.commercify.commercify.service.JwtService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor -public class AuthenticationController { - private final JwtService jwtService; - private final AuthenticationService authenticationService; - - @PostMapping("/signup") - public ResponseEntity register(@RequestBody RegisterUserRequest registerRequest) { - try { - UserDTO user = authenticationService.registerUser(registerRequest); - return ResponseEntity.ok(AuthResponse.UserAuthenticated(user, "", 0)); - } catch (RuntimeException e) { - log.error("Error registering user: {}", e.getMessage()); - return ResponseEntity.badRequest().body(AuthResponse.AuthenticationFailed(e.getMessage())); - } - } - - @PostMapping("/signin") - public ResponseEntity login(@RequestBody LoginUserRequest loginRequest) { - try { - UserDTO authenticatedUser = authenticationService.authenticate(loginRequest); - String jwtToken = jwtService.generateToken(authenticatedUser); - return ResponseEntity.ok(AuthResponse.UserAuthenticated(authenticatedUser, jwtToken, jwtService.getExpirationTime())); - } catch (Exception e) { - return ResponseEntity.badRequest().body(AuthResponse.AuthenticationFailed(e.getMessage())); - } - } - - @PutMapping("/{id}/register") - @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") - public ResponseEntity registerGuest(@PathVariable Long id, @RequestBody RegisterUserRequest request) { - try { - authenticationService.convertGuestToUser(id, request); - - UserDTO authenticatedUser = authenticationService.authenticate(new LoginUserRequest(request.email(), request.password())); - String jwtToken = jwtService.generateToken(authenticatedUser); - return ResponseEntity.ok(AuthResponse.UserAuthenticated(authenticatedUser, jwtToken, jwtService.getExpirationTime())); - } catch (Exception e) { - log.error("Error converting guest to a user: {}", e.getMessage()); - return ResponseEntity.badRequest().body(AuthResponse.AuthenticationFailed(e.getMessage())); - } - } - - @GetMapping("/me") - public ResponseEntity getAuthenticatedUser(@RequestHeader("Authorization") String authHeader) { - try { - return ResponseEntity.ok(authenticationService.getAuthenticatedUser(authHeader)); - } catch (Exception e) { - return ResponseEntity.badRequest().body(null); - } - } - - @PostMapping("/guest") - public ResponseEntity registerGuest() { - try { - UserDTO user = authenticationService.registerGuest(); - String jwt = jwtService.generateToken(user); - return ResponseEntity.ok(AuthResponse.UserAuthenticated(user, jwt, jwtService.getExpirationTime())); - } catch (RuntimeException e) { - log.error("Error registering guest: {}", e.getMessage()); - return ResponseEntity.badRequest().body(AuthResponse.AuthenticationFailed(e.getMessage())); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/controller/OrderController.java b/src/main/java/com/zenfulcode/commercify/commercify/controller/OrderController.java deleted file mode 100644 index 44430a3..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/controller/OrderController.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.zenfulcode.commercify.commercify.controller; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderRequest; -import com.zenfulcode.commercify.commercify.api.requests.orders.OrderStatusUpdateRequest; -import com.zenfulcode.commercify.commercify.api.responses.ErrorResponse; -import com.zenfulcode.commercify.commercify.api.responses.orders.CreateOrderResponse; -import com.zenfulcode.commercify.commercify.api.responses.orders.GetOrderResponse; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.exception.*; -import com.zenfulcode.commercify.commercify.service.order.OrderService; -import com.zenfulcode.commercify.commercify.viewmodel.OrderViewModel; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PagedResourcesAssembler; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.Set; - -@RestController -@RequestMapping("/api/v1/orders") -@AllArgsConstructor -@Slf4j -public class OrderController { - - private final OrderService orderService; - private final PagedResourcesAssembler pagedResourcesAssembler; - - private static final Set VALID_SORT_FIELDS = Set.of( - "id", "userId", "status", "currency", "totalAmount", "createdAt", "updatedAt" - ); - - @PreAuthorize("#userId == authentication.principal.id") - @PostMapping("/{userId}") - public ResponseEntity createOrder(@PathVariable Long userId, @RequestBody CreateOrderRequest orderRequest) { - try { - OrderDTO orderDTO = orderService.createOrder(userId, orderRequest); - return ResponseEntity.ok(CreateOrderResponse.from(OrderViewModel.fromDTO(orderDTO))); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Invalid request: " + e.getMessage())); - } catch (ProductNotFoundException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Product not found: " + e.getMessage())); - } catch (InsufficientStockException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Insufficient stock: " + e.getMessage())); - } catch (OrderValidationException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from(e.getMessage())); - } catch (Exception e) { - log.error("Error creating order", e); - return ResponseEntity.internalServerError() - .body(CreateOrderResponse.from("Error creating order: " + e.getMessage())); - } - } - - @PostMapping - public ResponseEntity createOrder(@RequestBody CreateOrderRequest orderRequest) { - try { - OrderDTO orderDTO = orderService.createOrder(orderRequest); - return ResponseEntity.ok(CreateOrderResponse.from(OrderViewModel.fromDTO(orderDTO))); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Invalid request: " + e.getMessage())); - } catch (ProductNotFoundException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Product not found: " + e.getMessage())); - } catch (InsufficientStockException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from("Insufficient stock: " + e.getMessage())); - } catch (OrderValidationException e) { - return ResponseEntity.badRequest() - .body(CreateOrderResponse.from(e.getMessage())); - } catch (Exception e) { - log.error("Error creating order", e); - return ResponseEntity.internalServerError() - .body(CreateOrderResponse.from("Error creating order: " + e.getMessage())); - } - } - - @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')") - @GetMapping("/user/{userId}") - public ResponseEntity getOrdersByUserId( - @PathVariable Long userId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") String sortDirection - ) { - try { - validateSortField(sortBy); - Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); - - Page orders = orderService.getOrdersByUserId(userId, pageRequest) - .map(OrderViewModel::fromDTO); - - return ResponseEntity.ok(pagedResourcesAssembler.toModel(orders)); - } catch (IllegalArgumentException e) { - log.error("Invalid request parameters", e); - return ResponseEntity.badRequest() - .body(new ErrorResponse("Invalid request parameters: " + e.getMessage())); - } catch (Exception e) { - log.error("Error retrieving orders", e); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error retrieving orders: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @GetMapping - public ResponseEntity getAllOrders( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") String sortDirection - ) { - try { - validateSortField(sortBy); - Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); - - Page orders = orderService.getAllOrders(pageRequest) - .map(OrderViewModel::fromDTO); - - return ResponseEntity.ok(pagedResourcesAssembler.toModel(orders)); - } catch (IllegalArgumentException e) { - log.error("Invalid request parameters", e); - return ResponseEntity.badRequest() - .body(new ErrorResponse("Invalid request parameters: " + e.getMessage())); - } catch (Exception e) { - log.error("Error retrieving orders", e); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error retrieving orders: " + e.getMessage())); - } - } - - @PreAuthorize("@orderService.isOrderOwnedByUser(#orderId, authentication.principal.id) or hasRole('ADMIN')") - @GetMapping("/{orderId}") - public ResponseEntity getOrderById(@PathVariable Long orderId) { - try { - OrderDetailsDTO orderDetails = orderService.getOrderById(orderId); - return ResponseEntity.ok(GetOrderResponse.from(orderDetails)); - } catch (OrderNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error retrieving order", e); - return ResponseEntity.badRequest().build(); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PutMapping("/{orderId}/status") - public ResponseEntity updateOrderStatus( - @PathVariable Long orderId, - @Validated @RequestBody OrderStatusUpdateRequest request - ) { - try { - OrderStatus orderStatus = OrderStatus.valueOf(request.status().toUpperCase()); - orderService.updateOrderStatus(orderId, orderStatus); - return ResponseEntity.ok().build(); - } catch (OrderNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body(new ErrorResponse("Invalid order status: " + e.getMessage())); - } catch (IllegalStateException e) { - return ResponseEntity.badRequest() - .body(new ErrorResponse("Invalid status transition: " + e.getMessage())); - } catch (Exception e) { - log.error("Error updating order status", e); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error updating order status: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{orderId}") - public ResponseEntity cancelOrder(@PathVariable Long orderId) { - try { - orderService.cancelOrder(orderId); - return ResponseEntity.ok().build(); - } catch (OrderNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (IllegalStateException e) { - return ResponseEntity.badRequest() - .body(new ErrorResponse("Cannot cancel order: " + e.getMessage())); - } catch (Exception e) { - log.error("Error canceling order", e); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error canceling order: " + e.getMessage())); - } - } - - private void validateSortField(String sortBy) { - if (!VALID_SORT_FIELDS.contains(sortBy)) { - throw new InvalidSortFieldException("Invalid sort field: " + sortBy); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/controller/PaymentController.java b/src/main/java/com/zenfulcode/commercify/commercify/controller/PaymentController.java deleted file mode 100644 index c9358e6..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/controller/PaymentController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.zenfulcode.commercify.commercify.controller; - -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.api.requests.CapturePaymentRequest; -import com.zenfulcode.commercify.commercify.integration.mobilepay.MobilePayService; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@CrossOrigin -@RestController -@RequestMapping("/api/v1/payments") -@AllArgsConstructor -@Slf4j -public class PaymentController { - private final MobilePayService mobilePayService; - - @PostMapping("/{orderId}/status") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity updatePaymentStatus( - @PathVariable Long orderId, - @RequestParam PaymentStatus status) { - try { - mobilePayService.handlePaymentStatusUpdate(orderId, status); - return ResponseEntity.ok("Payment status updated successfully"); - } catch (Exception e) { - log.error("Error updating payment status", e); - return ResponseEntity.badRequest().body("Error updating payment status"); - } - } - - @GetMapping("/{orderId}/status") - public ResponseEntity getPaymentStatus(@PathVariable Long orderId) { - PaymentStatus status = mobilePayService.getPaymentStatus(orderId); - return ResponseEntity.ok(status); - } - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/{orderId}/capture") - public ResponseEntity capturePayment(@PathVariable Long orderId, @RequestBody CapturePaymentRequest request) { - try { - mobilePayService.capturePayment(orderId, request.captureAmount(), request.isPartialCapture()); - return ResponseEntity.ok("Payment captured successfully"); - } catch (Exception e) { - log.error("Error capturing payment", e); - return ResponseEntity.badRequest().body("Error capturing payment"); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/controller/ProductController.java b/src/main/java/com/zenfulcode/commercify/commercify/controller/ProductController.java deleted file mode 100644 index 6180ac7..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/controller/ProductController.java +++ /dev/null @@ -1,299 +0,0 @@ -package com.zenfulcode.commercify.commercify.controller; - -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductVariantRequest; -import com.zenfulcode.commercify.commercify.api.responses.ErrorResponse; -import com.zenfulcode.commercify.commercify.api.responses.ValidationErrorResponse; -import com.zenfulcode.commercify.commercify.api.responses.products.ProductDeletionErrorResponse; -import com.zenfulcode.commercify.commercify.api.responses.products.ProductUpdateResponse; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; -import com.zenfulcode.commercify.commercify.dto.ProductUpdateResult; -import com.zenfulcode.commercify.commercify.dto.ProductVariantEntityDto; -import com.zenfulcode.commercify.commercify.exception.InvalidSortFieldException; -import com.zenfulcode.commercify.commercify.exception.ProductDeletionException; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.exception.ProductValidationException; -import com.zenfulcode.commercify.commercify.service.product.ProductService; -import com.zenfulcode.commercify.commercify.service.product.ProductVariantService; -import com.zenfulcode.commercify.commercify.viewmodel.ProductVariantViewModel; -import com.zenfulcode.commercify.commercify.viewmodel.ProductViewModel; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PagedResourcesAssembler; -import org.springframework.hateoas.EntityModel; -import org.springframework.hateoas.PagedModel; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.Set; - -@RestController -@RequestMapping("/api/v1/products") -@AllArgsConstructor -@Slf4j -public class ProductController { - private final ProductService productService; - private final ProductVariantService variantService; - private final PagedResourcesAssembler productPageAssembler; - private final PagedResourcesAssembler variantPageAssembler; - - private static final Set VALID_SORT_FIELDS = Set.of( - "id", "name", "stock", "createdAt", "updatedAt" - ); - - @PreAuthorize("hasRole('ADMIN')") - @GetMapping - public ResponseEntity>> getAllProducts( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") String sortDirection - ) { - validateSortField(sortBy); - Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); - - Page products = productService.getAllProducts(pageRequest) - .map(ProductViewModel::fromDTO); - - return ResponseEntity.ok(productPageAssembler.toModel(products)); - } - - @GetMapping("/active") - public ResponseEntity>> getActiveProducts( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") String sortDirection - ) { - validateSortField(sortBy); - Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); - - Page products = productService.getActiveProducts(pageRequest) - .map(ProductViewModel::fromDTO); - - return ResponseEntity.ok(productPageAssembler.toModel(products)); - } - - @GetMapping("/{id}") - public ResponseEntity getProductById(@PathVariable Long id) { - try { - ProductDTO product = productService.getProductById(id); - return ResponseEntity.ok(ProductViewModel.fromDTO(product)); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error retrieving product {}", e.getMessage()); - return ResponseEntity.internalServerError().build(); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping - public ResponseEntity createProduct(@Validated @RequestBody ProductRequest request) { - try { - ProductDTO product = productService.saveProduct(request); - return ResponseEntity.ok(ProductViewModel.fromDTO(product)); - } catch (ProductValidationException e) { - return ResponseEntity.badRequest().body(new ValidationErrorResponse(e.getErrors())); - } catch (Exception e) { - log.error("Error creating product {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error creating product: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PutMapping("/{id}") - public ResponseEntity updateProduct(@PathVariable Long id, @Validated @RequestBody ProductRequest request) { - try { - ProductUpdateResult result = productService.updateProduct(id, request); - if (!result.getWarnings().isEmpty()) { - return ResponseEntity.ok() - .body(new ProductUpdateResponse( - ProductViewModel.fromDTO(result.getProduct()), - "Product updated with warnings", - result.getWarnings() - )); - } - return ResponseEntity.ok(ProductViewModel.fromDTO(result.getProduct())); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (ProductValidationException e) { - return ResponseEntity.badRequest() - .body(new ValidationErrorResponse(e.getErrors())); - } catch (Exception e) { - log.error("Error updating product {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error updating product: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/{id}/reactivate") - public ResponseEntity reactivateProduct(@PathVariable Long id) { - try { - productService.reactivateProduct(id); - return ResponseEntity.ok("Product reactivated"); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error reactivating product {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error reactivating product: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/{id}/deactivate") - public ResponseEntity deactivateProduct(@PathVariable Long id) { - try { - productService.deactivateProduct(id); - return ResponseEntity.ok("Product deactivated"); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error deactivating product {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error deactivating product: " + e.getMessage())); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{id}") - public ResponseEntity deleteProduct(@PathVariable Long id) { - try { - productService.deleteProduct(id); - return ResponseEntity.ok().build(); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (ProductDeletionException e) { - return ResponseEntity.badRequest() - .body(new ProductDeletionErrorResponse( - "Cannot delete product", - e.getIssues(), - e.getActiveOrders() - )); - } catch (Exception e) { - log.error("Error deleting product {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error deleting product: " + e.getMessage())); - } - } - - // Variant endpoints - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/{productId}/variants") - public ResponseEntity addVariant( - @PathVariable Long productId, - @Validated @RequestBody ProductVariantRequest request - ) { - try { - ProductVariantEntityDto variant = variantService.addVariant(productId, request); - return ResponseEntity.ok(ProductVariantViewModel.fromDTO(variant)); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (ProductValidationException e) { - return ResponseEntity.badRequest() - .body(new ValidationErrorResponse(e.getErrors())); - } catch (Exception e) { - log.error("Error adding variant {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error adding variant: " + e.getMessage())); - } - } - - // TODO: DELETING variant options instead of just updating them - @PreAuthorize("hasRole('ADMIN')") - @PutMapping("/{productId}/variants/{variantId}") - public ResponseEntity updateVariant( - @PathVariable Long productId, - @PathVariable Long variantId, - @Validated @RequestBody ProductVariantRequest request - ) { - try { - ProductVariantEntityDto variant = variantService.updateVariant(productId, variantId, request); - return ResponseEntity.ok(ProductVariantViewModel.fromDTO(variant)); - } catch (ProductNotFoundException | IllegalArgumentException e) { - return ResponseEntity.notFound().build(); - } catch (ProductValidationException e) { - return ResponseEntity.badRequest() - .body(new ValidationErrorResponse(e.getErrors())); - } catch (Exception e) { - log.error("Error updating variant{}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error updating variant: " + e.getMessage())); - } - } - - // TODO: the variant doesnt seem to get deleted - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/{productId}/variants/{variantId}") - public ResponseEntity deleteVariant(@PathVariable Long productId, @PathVariable Long variantId) { - try { - variantService.deleteVariant(productId, variantId); - return ResponseEntity.ok("Deleted"); - } catch (ProductNotFoundException | IllegalArgumentException e) { - return ResponseEntity.notFound().build(); - } catch (ProductDeletionException e) { - return ResponseEntity.badRequest() - .body(new ProductDeletionErrorResponse( - "Cannot delete variant", - e.getIssues(), - e.getActiveOrders() - )); - } catch (Exception e) { - log.error("Error deleting variant {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(new ErrorResponse("Error deleting variant: " + e.getMessage())); - } - } - - @GetMapping("/{productId}/variants") - public ResponseEntity>> getProductVariants( - @PathVariable Long productId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size - ) { - try { - PageRequest pageRequest = PageRequest.of(page, size); - Page variants = variantService.getProductVariants(productId, pageRequest) - .map(ProductVariantViewModel::fromDTO); - - return ResponseEntity.ok(variantPageAssembler.toModel(variants)); - } catch (ProductNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error retrieving variants {}", e.getMessage()); - return ResponseEntity.internalServerError().build(); - } - } - - @GetMapping("/{productId}/variants/{variantId}") - public ResponseEntity getVariant( - @PathVariable Long productId, - @PathVariable Long variantId - ) { - try { - ProductVariantEntityDto variant = variantService.getVariantDto(productId, variantId); - return ResponseEntity.ok(ProductVariantViewModel.fromDTO(variant)); - } catch (ProductNotFoundException | IllegalArgumentException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("Error retrieving variant {}", e.getMessage()); - return ResponseEntity.internalServerError().build(); - } - } - - private void validateSortField(String sortBy) { - if (!VALID_SORT_FIELDS.contains(sortBy)) { - throw new InvalidSortFieldException("Invalid sort field: " + sortBy); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/controller/UserManagementController.java b/src/main/java/com/zenfulcode/commercify/commercify/controller/UserManagementController.java deleted file mode 100644 index 7976f86..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/controller/UserManagementController.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.zenfulcode.commercify.commercify.controller; - - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.service.UserManagementService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PagedResourcesAssembler; -import org.springframework.hateoas.EntityModel; -import org.springframework.hateoas.PagedModel; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/v1/users") -@RequiredArgsConstructor -public class UserManagementController { - private final UserManagementService userManagementService; - private final PagedResourcesAssembler pagedResourcesAssembler; - - @GetMapping("/{id}") - @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") - public ResponseEntity getUserById(@PathVariable Long id) { - return ResponseEntity.ok(userManagementService.getUserById(id)); - } - - @GetMapping - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity>> getAllUsers( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") String sortDirection - ) { - Sort.Direction direction = Sort.Direction.fromString(sortDirection.toUpperCase()); - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(direction, sortBy)); - - Page users = userManagementService.getAllUsers(pageRequest); - - return ResponseEntity.ok(pagedResourcesAssembler.toModel(users)); - } - - @PutMapping("/{id}") - @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") - public ResponseEntity updateUser(@PathVariable Long id, @RequestBody UserDTO userDTO) { - return ResponseEntity.ok(userManagementService.updateUser(id, userDTO)); - } - - @DeleteMapping("/{id}") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity deleteUser(@PathVariable Long id) { - userManagementService.deleteUser(id); - return ResponseEntity.ok().build(); - } - - @PostMapping("/{id}/address") - @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") - public ResponseEntity setAddress(@PathVariable Long id, @RequestBody AddressDTO request) { - return ResponseEntity.ok(userManagementService.setDefaultAddress(id, request)); - } - - @DeleteMapping("/{id}/address") - @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") - public ResponseEntity removeAddress(@PathVariable Long id) { - return ResponseEntity.ok(userManagementService.removeDefaultAddress(id)); - } - - @PostMapping("/{id}/roles") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity addRoleToUser(@PathVariable Long id, @RequestBody String role) { - return ResponseEntity.ok(userManagementService.addRoleToUser(id, role)); - } - - @DeleteMapping("/{id}/roles") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity removeRoleFromUser(@PathVariable Long id, @RequestBody String role) { - return ResponseEntity.ok(userManagementService.removeRoleFromUser(id, role)); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/AddressDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/AddressDTO.java deleted file mode 100644 index 692c3d0..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/AddressDTO.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Builder -@Data -@AllArgsConstructor -public class AddressDTO { - private Long id; - private String street; - private String city; - private String state; - private String zipCode; - private String country; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/CustomerDetailsDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/CustomerDetailsDTO.java deleted file mode 100644 index 52f8568..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/CustomerDetailsDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Builder -@Data -@AllArgsConstructor -public class CustomerDetailsDTO { - private String firstName; - private String lastName; - private String email; - private String phone; - - public String getFullName() { - return firstName + " " + lastName; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDTO.java deleted file mode 100644 index 34508d9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDTO.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -import java.time.Instant; - -@Data -@Builder -@AllArgsConstructor -public class OrderDTO { - private long id; - private Long userId; - private String currency; - private double subTotal; - private double shippingCost; - private OrderStatus orderStatus; - private int orderLinesAmount; - private Instant createdAt; - private Instant updatedAt; - - public OrderDTO() { - - } - - public double getTotal() { - return subTotal + shippingCost; - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDetailsDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDetailsDTO.java deleted file mode 100644 index c3f1d61..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderDetailsDTO.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -import java.util.List; - -@Builder -@Data -@AllArgsConstructor -public class OrderDetailsDTO { - private OrderDTO order; - private List orderLines; - private CustomerDetailsDTO customerDetails; - private AddressDTO shippingAddress; - private AddressDTO billingAddress; - - public OrderDetailsDTO() { - - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderLineDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderLineDTO.java deleted file mode 100644 index a3e33d7..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/OrderLineDTO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -@AllArgsConstructor -public class OrderLineDTO { - private Long id; - private Long productId; - private Long variantId; - private Integer quantity; - private Double unitPrice; - private String currency; - private ProductDTO product; - private ProductVariantEntityDto variant; -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDTO.java deleted file mode 100644 index b1f67f9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDTO.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -import java.util.List; - -@Builder -@Data -@AllArgsConstructor -public class ProductDTO { - private Long id; - private String name; - private String description; - private Integer stock; - private Boolean active; - private String imageUrl; - private Double unitPrice; - private String currency; - private List variants; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDeletionValidationResult.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDeletionValidationResult.java deleted file mode 100644 index 7cc535b..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductDeletionValidationResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.util.List; - -@Data -@AllArgsConstructor -public class ProductDeletionValidationResult { - private boolean canDelete; - private List issues; - private List activeOrders; - - public boolean canDelete() { - return activeOrders.isEmpty() && issues.isEmpty(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductUpdateResult.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductUpdateResult.java deleted file mode 100644 index 5afdb4e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductUpdateResult.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -import java.util.List; - -@Data -@AllArgsConstructor -@Builder -public class ProductUpdateResult { - private ProductDTO product; - private List warnings; - - public static ProductUpdateResult withWarnings(ProductDTO product, List warnings) { - return new ProductUpdateResult(product, warnings); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductVariantEntityDto.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductVariantEntityDto.java deleted file mode 100644 index f384f78..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/ProductVariantEntityDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.Builder; -import lombok.Data; - -import java.io.Serializable; -import java.util.Set; - -/** - * DTO for {@link com.zenfulcode.commercify.commercify.entity.ProductVariantEntity} - */ -@Builder -@Data -public class ProductVariantEntityDto implements Serializable { - Long id; - String sku; - Integer stock; - String imageUrl; - Double unitPrice; - Set options; -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/UserDTO.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/UserDTO.java deleted file mode 100644 index ea9c9d9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/UserDTO.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -import java.util.Date; -import java.util.List; - -@Builder -@Data -@AllArgsConstructor -public class UserDTO { - private Long id; - private String email; - private String firstName; - private String lastName; - private Date createdAt; - private AddressDTO defaultAddress; - private List roles; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/VariantOptionEntityDto.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/VariantOptionEntityDto.java deleted file mode 100644 index e1035a7..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/VariantOptionEntityDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -/** - * DTO for {@link com.zenfulcode.commercify.commercify.entity.VariantOptionEntity} - */ -@Builder -@Data -@AllArgsConstructor -public class VariantOptionEntityDto { - private Long id; - private String name; - private String value; -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/AddressMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/AddressMapper.java deleted file mode 100644 index 0c8f37d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/AddressMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import org.springframework.stereotype.Service; - -import java.util.function.Function; - -@Service -public class AddressMapper implements Function { - @Override - public AddressDTO apply(AddressEntity address) { - return AddressDTO.builder() - .id(address.getId()) - .street(address.getStreet()) - .city(address.getCity()) - .state(address.getState()) - .zipCode(address.getZipCode()) - .country(address.getCountry()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderLineMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderLineMapper.java deleted file mode 100644 index cf692a5..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderLineMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.OrderLineDTO; -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.function.Function; - -@Service -@AllArgsConstructor -public class OrderLineMapper implements Function { - @Override - public OrderLineDTO apply(OrderLineEntity orderLine) { - return OrderLineDTO.builder() - .id(orderLine.getId()) - .quantity(orderLine.getQuantity()) - .productId(orderLine.getProductId()) - .unitPrice(orderLine.getUnitPrice()) - .build(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapper.java deleted file mode 100644 index 1a96e7d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapper.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.function.Function; - -@Service -@RequiredArgsConstructor -public class OrderMapper implements Function { - - @Override - public OrderDTO apply(OrderEntity order) { - return OrderDTO.builder() - .id(order.getId()) - .userId(order.getUserId()) - .orderStatus(order.getStatus()) - .createdAt(order.getCreatedAt()) - .updatedAt(order.getUpdatedAt()) - .shippingCost(order.getShippingCost() != null ? order.getShippingCost() : 0.0) - .currency(order.getCurrency() != null ? order.getCurrency() : null) - .subTotal(order.getSubTotal() != null ? order.getSubTotal() : 0.0) - .orderLinesAmount(order.getOrderLines() != null ? order.getOrderLines().size() : 0) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductMapper.java deleted file mode 100644 index c87ede9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.ProductDTO; -import com.zenfulcode.commercify.commercify.dto.ProductVariantEntityDto; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.function.Function; - -@Service -@RequiredArgsConstructor -public class ProductMapper implements Function { - private final ProductVariantMapper variantMapper; - - @Override - public ProductDTO apply(ProductEntity product) { - final List variants = product.getVariants().stream().map(variantMapper).toList(); - - return ProductDTO.builder() - .id(product.getId()) - .name(product.getName()) - .description(product.getDescription()) - .stock(product.getStock()) - .active(product.getActive()) - .imageUrl(product.getImageUrl()) - .unitPrice(product.getUnitPrice()) - .currency(product.getCurrency()) - .variants(variants) - .build(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductVariantMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductVariantMapper.java deleted file mode 100644 index 1595d84..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/ProductVariantMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.ProductVariantEntityDto; -import com.zenfulcode.commercify.commercify.dto.VariantOptionEntityDto; -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class ProductVariantMapper implements Function { - private final VariantOptionMapper variantOptionMapper; - - @Override - public ProductVariantEntityDto apply(ProductVariantEntity productVariant) { - Set options = productVariant.getOptions().stream() - .map(variantOptionMapper).collect(Collectors.toSet()); - - return ProductVariantEntityDto.builder() - .id(productVariant.getId()) - .sku(productVariant.getSku()) - .stock(productVariant.getStock()) - .imageUrl(productVariant.getImageUrl()) - .unitPrice(productVariant.getUnitPrice()) - .options(options) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/UserMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/UserMapper.java deleted file mode 100644 index 47a6489..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/UserMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.stereotype.Service; - -import java.util.Date; -import java.util.function.Function; - -@Service -@RequiredArgsConstructor -public class UserMapper implements Function { - private final AddressMapper addressDTOMapper; - - @Override - public UserDTO apply(UserEntity user) { - UserDTO.UserDTOBuilder userBuilder = UserDTO.builder() - .id(user.getId()) - .email(user.getEmail()) - .firstName(user.getFirstName()) - .lastName(user.getLastName()) - .roles(user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()) - .createdAt(Date.from(user.getCreatedAt())); - - if (user.getDefaultAddress() != null) { - userBuilder.defaultAddress(addressDTOMapper.apply(user.getDefaultAddress())); - } - - return userBuilder.build(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/VariantOptionMapper.java b/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/VariantOptionMapper.java deleted file mode 100644 index 7af638c..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/dto/mapper/VariantOptionMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.dto.VariantOptionEntityDto; -import com.zenfulcode.commercify.commercify.entity.VariantOptionEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.function.Function; - -@Service -@RequiredArgsConstructor -public class VariantOptionMapper implements Function { - - @Override - public VariantOptionEntityDto apply(VariantOptionEntity product) { - return VariantOptionEntityDto.builder() - .id(product.getId()) - .name(product.getName()) - .value(product.getValue()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/AddressEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/AddressEntity.java deleted file mode 100644 index 72d4e32..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/AddressEntity.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; - -@Entity -@Table(name = "addresses") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AddressEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String street; - - @Column(nullable = false) - private String city; - - private String state; - - @Column(name = "zip_code", nullable = false) - private String zipCode; - - @Column(nullable = false) - private String country; - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/ConfirmationTokenEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/ConfirmationTokenEntity.java deleted file mode 100644 index 1d5ba16..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/ConfirmationTokenEntity.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.time.Instant; -import java.util.UUID; - -@Entity -@Table(name = "confirmation_tokens") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class ConfirmationTokenEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String token; - - @Column(nullable = false, name = "expiry_date") - private Instant expiryDate; - - @Column(nullable = false) - private boolean confirmed; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private UserEntity user; - - @CreationTimestamp - @Column(nullable = false, name = "created_at") - private Instant createdAt; - - @PrePersist - public void prePersist() { - if (token == null) { - token = UUID.randomUUID().toString(); - } - if (expiryDate == null) { - expiryDate = Instant.now().plusSeconds(24 * 60 * 60); // 24 hours - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderEntity.java deleted file mode 100644 index 01cb693..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderEntity.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; -import org.hibernate.proxy.HibernateProxy; - -import java.time.Instant; -import java.util.LinkedHashSet; -import java.util.Objects; -import java.util.Set; - -@Entity -@Table(name = "orders", indexes = { - @Index(name = "idx_orders_user_id", columnList = "user_id"), - @Index(name = "idx_orders_status", columnList = "status"), - @Index(name = "idx_orders_user_id_status", columnList = "user_id, status") -}) -@Getter -@Setter -@ToString -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class OrderEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @Column(name = "user_id") - private Long userId; - - @ToString.Exclude - @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private Set orderLines = new LinkedHashSet<>(); - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private OrderStatus status; - - private String currency; - @Column(name = "sub_total") - private Double subTotal; - - @Column(name = "shipping_cost") - private Double shippingCost; - - @ToString.Exclude - @ManyToOne(cascade = CascadeType.ALL) - @JoinColumn(name = "order_shipping_info_id") - private OrderShippingInfo orderShippingInfo; - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - - public double getTotal() { - return subTotal + shippingCost; - } - - @Override - public final boolean equals(Object o) { - if (this == o) return true; - if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); - if (thisEffectiveClass != oEffectiveClass) return false; - OrderEntity that = (OrderEntity) o; - return getId() != null && Objects.equals(getId(), that.getId()); - } - - @Override - public final int hashCode() { - return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderLineEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderLineEntity.java deleted file mode 100644 index ccaac1d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderLineEntity.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.proxy.HibernateProxy; - -import java.util.Objects; - -@Builder -@Getter -@Setter -@Entity -@Table(name = "order_lines") -@NoArgsConstructor -@AllArgsConstructor -public class OrderLineEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(nullable = false) - private Long id; - - @Column(name = "product_id", nullable = false, updatable = false) - private Long productId; - - @Column(nullable = false, updatable = false) - private Integer quantity; - - @Column(name = "unit_price", nullable = false, updatable = false) - private Double unitPrice; - - @Column(nullable = false, updatable = false) - private String currency; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_variant_id") - private ProductVariantEntity productVariant; - - @ManyToOne(optional = false) - @JoinColumn(name = "order_id") - private OrderEntity order; - - @Override - public final boolean equals(Object o) { - if (this == o) return true; - if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); - if (thisEffectiveClass != oEffectiveClass) return false; - OrderLineEntity that = (OrderLineEntity) o; - return getId() != null && Objects.equals(getId(), that.getId()); - } - - @Override - public final int hashCode() { - return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); - } - - @Override - public String toString() { - return getClass().getSimpleName() + "(" + - "id = " + id + ", " + - "productId = " + productId + ", " + - "quantity = " + quantity + ", " + - "unitPrice = " + unitPrice + ", " + - "currency = " + currency + ", " + - "variantId = " + productVariant + ")"; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderShippingInfo.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderShippingInfo.java deleted file mode 100644 index ba9ed24..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/OrderShippingInfo.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; - -@Table(name = "order_shipping_info") -@Entity -@Getter -@Setter -@ToString -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class OrderShippingInfo { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @Column(name = "customer_first_name") - private String customerFirstName; - @Column(name = "customer_last_name") - private String customerLastName; - @Column(name = "customer_email") - private String customerEmail; - @Column(name = "customer_phone") - private String customerPhone; - - @Column(name = "shipping_street", nullable = false) - private String shippingStreet; - @Column(name = "shipping_city", nullable = false) - private String shippingCity; - @Column(name = "shipping_state") - private String shippingState; - @Column(name = "shipping_zip", nullable = false) - private String shippingZip; - @Column(name = "shipping_country", nullable = false) - private String shippingCountry; - - @Column(name = "billing_street") - private String billingStreet; - @Column(name = "billing_city") - private String billingCity; - @Column(name = "billing_state") - private String billingState; - @Column(name = "billing_zip") - private String billingZip; - @Column(name = "billing_country") - private String billingCountry; -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/PaymentEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/PaymentEntity.java deleted file mode 100644 index 9c1c51d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/PaymentEntity.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import com.zenfulcode.commercify.commercify.PaymentProvider; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; -import org.hibernate.proxy.HibernateProxy; - -import java.time.Instant; -import java.util.Objects; - -@Entity -@Table(name = "payments") -@Getter -@Setter -@ToString -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PaymentEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "payment_reference", unique = true) - private String mobilePayReference; - - @Column(name = "order_id", nullable = false) - private Long orderId; - @Column(name = "total_amount") - private Double totalAmount; - @Column(name = "payment_method") - private String paymentMethod; // e.g., WALLET; CARD - @Column(name = "payment_provider") - @Enumerated(EnumType.STRING) - private PaymentProvider paymentProvider; - - @Enumerated(EnumType.STRING) - private PaymentStatus status; - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - - @Override - public final boolean equals(Object o) { - if (this == o) return true; - if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); - if (thisEffectiveClass != oEffectiveClass) return false; - PaymentEntity that = (PaymentEntity) o; - return getId() != null && Objects.equals(getId(), that.getId()); - } - - @Override - public final int hashCode() { - return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductEntity.java deleted file mode 100644 index a828025..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductEntity.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; -import java.util.HashSet; -import java.util.Set; - -@Entity -@Table(name = "products") -@Builder -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class ProductEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - private String name; - private String description; - private Integer stock; - private Boolean active; - @Column(name = "image_url") - private String imageUrl; - - // Prices - private String currency; - @Column(name = "unit_price") - private Double unitPrice; - - @OneToMany(mappedBy = "product", fetch = FetchType.EAGER, cascade = CascadeType.ALL) - @Builder.Default - private Set variants = new HashSet<>(); - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - - public void addVariant(ProductVariantEntity variant) { - variants.add(variant); - variant.setProduct(this); - } - - @Override - public String toString() { - return getClass().getSimpleName() + "(" + - "id = " + id + ", " + - "name = " + name + ", " + - "description = " + description + ", " + - "stock = " + stock + ", " + - "active = " + active + ", " + - "imageUrl = " + imageUrl + ", " + - "currency = " + currency + ", " + - "unitPrice = " + unitPrice + ")"; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductVariantEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductVariantEntity.java deleted file mode 100644 index bd9968f..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/ProductVariantEntity.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; -import java.util.HashSet; -import java.util.Set; - -@Builder -@Getter -@Setter -@Entity -@Table(name = "product_variants") -@AllArgsConstructor -@NoArgsConstructor -public class ProductVariantEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String sku; - - private Integer stock; - - @Column(name = "image_url") - private String imageUrl; - @Column(name = "unit_price") - private Double unitPrice; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id", nullable = false) - private ProductEntity product; - - @OneToMany(mappedBy = "productVariant", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private Set options = new HashSet<>(); - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - - public void addOption(VariantOptionEntity option) { - options.add(option); - option.setProductVariant(this); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/UserEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/UserEntity.java deleted file mode 100644 index 4a7954e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/UserEntity.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.time.Instant; -import java.util.*; -import java.util.stream.Collectors; - -@Table(name = "users") -@Entity -@Builder -@Getter -@Setter -@ToString -@NoArgsConstructor -@AllArgsConstructor -public class UserEntity implements UserDetails { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "first_name", length = 50, nullable = false) - private String firstName; - @Column(name = "last_name", length = 50, nullable = false) - private String lastName; - - @Column(unique = true, length = 100, nullable = false) - private String email; - private String password; - - @Column(name = "phone_number", length = 20) - private String phoneNumber; - - @ToString.Exclude - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "default_address_id", unique = true) - private AddressEntity defaultAddress; - - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "id")) - @Column(name = "role") - private List roles = new ArrayList<>(); - - @Column(nullable = false, name = "email_confirmed") - private Boolean emailConfirmed = false; - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @ToString.Exclude - private Set confirmationTokens = new HashSet<>(); - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; - - @Override - public Collection getAuthorities() { - return roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) - .collect(Collectors.toList()); - } - - public void addRole(String role) { - if (roles == null) { - roles = new ArrayList<>(); - } - - if (!roles.contains(role.toUpperCase())) { - roles.add(role.toUpperCase()); - } - } - - public void removeRole(String role) { - if (roles == null) { - return; - } - - roles.remove(role.toUpperCase()); - } - - @Override - public String getUsername() { - return email; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/entity/VariantOptionEntity.java b/src/main/java/com/zenfulcode/commercify/commercify/entity/VariantOptionEntity.java deleted file mode 100644 index 2a7fbd3..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/entity/VariantOptionEntity.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; - -@Builder -@Getter -@Setter -@Entity -@Table(name = "variant_option_entity") -@NoArgsConstructor -@AllArgsConstructor -public class VariantOptionEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String name; // e.g., "Size", "Color" - - @Column(nullable = false) - private String value; // e.g., "Large", "Red" - - @ManyToOne - @JoinColumn(name = "product_variant_id") - private ProductVariantEntity productVariant; - - @Column(name = "created_at", nullable = false) - @CreationTimestamp - private Instant createdAt; - - @Column(name = "updated_at") - @UpdateTimestamp - private Instant updatedAt; -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/GlobalExceptionHandler.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/GlobalExceptionHandler.java deleted file mode 100644 index 5334676..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -import io.jsonwebtoken.ExpiredJwtException; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ProblemDetail; -import org.springframework.security.authentication.AccountStatusException; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.nio.file.AccessDeniedException; -import java.security.SignatureException; - -@RestControllerAdvice -public class GlobalExceptionHandler { - @ExceptionHandler(Exception.class) - public ProblemDetail handleSecurityException(Exception exception) { - ProblemDetail errorDetail = null; - - - if (exception instanceof BadCredentialsException) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(401), exception.getMessage()); - errorDetail.setProperty("description", "The username or password is incorrect"); - - return errorDetail; - } - - if (exception instanceof AccountStatusException) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(403), exception.getMessage()); - errorDetail.setProperty("description", "The account is locked"); - } - - if (exception instanceof AccessDeniedException) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(403), exception.getMessage()); - errorDetail.setProperty("description", "You are not authorized to access this resource"); - } - - if (exception instanceof SignatureException) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(403), exception.getMessage()); - errorDetail.setProperty("description", "The JWT signature is invalid"); - } - - if (exception instanceof ExpiredJwtException) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(403), exception.getMessage()); - errorDetail.setProperty("description", "The JWT token has expired"); - } - - if (errorDetail == null) { - errorDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(500), exception.getMessage()); - errorDetail.setProperty("description", "Unknown internal server error."); - } - - return errorDetail; - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/InsufficientStockException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/InsufficientStockException.java deleted file mode 100644 index d3d92ce..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/InsufficientStockException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class InsufficientStockException extends RuntimeException { - public InsufficientStockException(String message) { - super(message); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/InvalidSortFieldException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/InvalidSortFieldException.java deleted file mode 100644 index 40e4b48..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/InvalidSortFieldException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.BAD_REQUEST) -public class InvalidSortFieldException extends RuntimeException { - public InvalidSortFieldException(String message) { - super(message); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderNotFoundException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderNotFoundException.java deleted file mode 100644 index 5040039..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class OrderNotFoundException extends RuntimeException { - public OrderNotFoundException(Long orderId) { - super("Order not found with ID: " + orderId); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderValidationException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderValidationException.java deleted file mode 100644 index 14fd35b..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/OrderValidationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.BAD_REQUEST) -public class OrderValidationException extends RuntimeException { - public OrderValidationException(String message) { - super(message); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/PaymentProcessingException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/PaymentProcessingException.java deleted file mode 100644 index ee2b152..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/PaymentProcessingException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class PaymentProcessingException extends RuntimeException { - public PaymentProcessingException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/PriceNotFoundException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/PriceNotFoundException.java deleted file mode 100644 index 13ccb98..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/PriceNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class PriceNotFoundException extends RuntimeException { - public PriceNotFoundException(Long priceId) { - super("Price not found with ID: " + priceId); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductDeletionException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductDeletionException.java deleted file mode 100644 index 7785ebe..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductDeletionException.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import lombok.Getter; - -import java.util.List; - -@Getter -public class ProductDeletionException extends RuntimeException { - private final List issues; - private final List activeOrders; - - public ProductDeletionException(String message, List issues, List activeOrders) { - super(message); - this.issues = issues; - this.activeOrders = activeOrders; - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductNotFoundException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductNotFoundException.java deleted file mode 100644 index 1439b68..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class ProductNotFoundException extends RuntimeException { - public ProductNotFoundException(Long productId) { - super("Product not found with ID: " + productId); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductValidationException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductValidationException.java deleted file mode 100644 index 26c9b4f..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/ProductValidationException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -import lombok.Getter; - -import java.util.List; - -@Getter -public class ProductValidationException extends RuntimeException { - private final List errors; - - public ProductValidationException(List errors) { - super("Product validation failed: " + String.join(", ", errors)); - this.errors = errors; - } - -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/exception/StripeOperationException.java b/src/main/java/com/zenfulcode/commercify/commercify/exception/StripeOperationException.java deleted file mode 100644 index 5d9e0b5..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/exception/StripeOperationException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.zenfulcode.commercify.commercify.exception; - -public class StripeOperationException extends RuntimeException { - - public StripeOperationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/factory/ProductFactory.java b/src/main/java/com/zenfulcode/commercify/commercify/factory/ProductFactory.java deleted file mode 100644 index 076da7e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/factory/ProductFactory.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.zenfulcode.commercify.commercify.factory; - -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class ProductFactory { - public ProductEntity createFromRequest(ProductRequest request) { - return ProductEntity.builder() - .name(request.name()) - .description(request.description()) - .stock(request.stock() != null ? request.stock() : 0) - .active(true) - .imageUrl(request.imageUrl()) - .currency(request.price().currency()) - .unitPrice(request.price().amount()) - .build(); - } -} - diff --git a/src/main/java/com/zenfulcode/commercify/commercify/flow/PaymentStateFlow.java b/src/main/java/com/zenfulcode/commercify/commercify/flow/PaymentStateFlow.java deleted file mode 100644 index 058f5e9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/flow/PaymentStateFlow.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.zenfulcode.commercify.commercify.flow; - -import com.zenfulcode.commercify.commercify.PaymentStatus; -import org.springframework.stereotype.Component; - -import java.util.EnumMap; -import java.util.Set; - -@Component -public class PaymentStateFlow { - private final EnumMap> validTransitions; - - public PaymentStateFlow() { - validTransitions = new EnumMap<>(PaymentStatus.class); - - // Initial state -> Paid, Failed, or Cancelled - validTransitions.put(PaymentStatus.PENDING, Set.of( - PaymentStatus.PAID, - PaymentStatus.FAILED, - PaymentStatus.CANCELLED, - PaymentStatus.EXPIRED - )); - - // Successful payment -> Refunded - validTransitions.put(PaymentStatus.PAID, Set.of( - PaymentStatus.REFUNDED - )); - - // Terminal states - validTransitions.put(PaymentStatus.FAILED, Set.of()); - validTransitions.put(PaymentStatus.CANCELLED, Set.of()); - validTransitions.put(PaymentStatus.REFUNDED, Set.of()); - validTransitions.put(PaymentStatus.EXPIRED, Set.of()); - validTransitions.put(PaymentStatus.TERMINATED, Set.of()); - } - - public boolean canTransition(PaymentStatus currentState, PaymentStatus newState) { - return validTransitions.get(currentState).contains(newState); - } - - public Set getValidTransitions(PaymentStatus currentState) { - return validTransitions.get(currentState); - } - - public boolean isTerminalState(PaymentStatus state) { - return validTransitions.get(state).isEmpty(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayController.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayController.java deleted file mode 100644 index 166dcaa..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayController.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.mobilepay; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.zenfulcode.commercify.commercify.api.requests.PaymentRequest; -import com.zenfulcode.commercify.commercify.api.requests.WebhookPayload; -import com.zenfulcode.commercify.commercify.api.responses.PaymentResponse; -import com.zenfulcode.commercify.commercify.integration.WebhookSubscribeRequest; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/payments/mobilepay") -@RequiredArgsConstructor -@Slf4j -public class MobilePayController { - private final MobilePayService mobilePayService; - - @PostMapping("/create") - public ResponseEntity createPayment(@RequestBody PaymentRequest request) { - try { - PaymentResponse response = mobilePayService.initiatePayment(request); - return ResponseEntity.ok(response); - } catch (Exception e) { - return ResponseEntity.badRequest().body(PaymentResponse.FailedPayment()); - } - } - - @PostMapping("/callback") - public ResponseEntity handleCallback( - @RequestBody String body, - HttpServletRequest request) { - String date = request.getHeader("x-ms-date"); - String contentSha256 = request.getHeader("x-ms-content-sha256"); - String authorization = request.getHeader("Authorization"); - - try { - // First authenticate the request with the raw string payload - mobilePayService.authenticateRequest(date, contentSha256, authorization, body, request); - log.info("Mobilepay Webhook authenticated"); - - // Convert the string payload to WebhookPayload object - ObjectMapper objectMapper = new ObjectMapper(); - WebhookPayload webhookPayload = objectMapper.readValue(body, WebhookPayload.class); - - // Pass the converted payload to handlePaymentCallback - mobilePayService.handlePaymentCallback(webhookPayload); - return ResponseEntity.ok("Callback processed successfully"); - } catch (JsonProcessingException e) { - log.error("Error parsing webhook payload", e); - return ResponseEntity.badRequest().body("Invalid payload format"); - } catch (Exception e) { - log.error("Error processing MobilePay callback", e); - return ResponseEntity.badRequest().body("Error processing callback"); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/webhooks") - public ResponseEntity registerWebhooks(@RequestBody WebhookSubscribeRequest request) { - try { - mobilePayService.registerWebhooks(request.callbackUrl()); - return ResponseEntity.ok("Webhooks registered successfully"); - } catch (Exception e) { - return ResponseEntity.badRequest().body("Error registering webhooks"); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/webhooks/{id}") - public ResponseEntity deleteWebhook(@PathVariable String id) { - try { - mobilePayService.deleteWebhook(id); - return ResponseEntity.ok("Webhook deleted successfully"); - } catch (RuntimeException e) { - return ResponseEntity.badRequest().body("Error deleting webhook"); - } - } - - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/webhooks") - public ResponseEntity getWebhooks() { - try { - System.out.println("Getting webhooks"); - Object response = mobilePayService.getWebhooks(); - return ResponseEntity.ok(response); - } catch (RuntimeException e) { - return ResponseEntity.badRequest().body("Error getting webhook"); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayService.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayService.java deleted file mode 100644 index 6b90270..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayService.java +++ /dev/null @@ -1,446 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.mobilepay; - -import com.zenfulcode.commercify.commercify.PaymentProvider; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.api.requests.PaymentRequest; -import com.zenfulcode.commercify.commercify.api.requests.WebhookPayload; -import com.zenfulcode.commercify.commercify.api.responses.PaymentResponse; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import com.zenfulcode.commercify.commercify.entity.WebhookConfigEntity; -import com.zenfulcode.commercify.commercify.exception.PaymentProcessingException; -import com.zenfulcode.commercify.commercify.integration.WebhookRegistrationResponse; -import com.zenfulcode.commercify.commercify.repository.PaymentRepository; -import com.zenfulcode.commercify.commercify.repository.WebhookConfigRepository; -import com.zenfulcode.commercify.commercify.service.PaymentService; -import com.zenfulcode.commercify.commercify.service.email.EmailService; -import com.zenfulcode.commercify.commercify.service.order.OrderService; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; - -@Service -@Slf4j -public class MobilePayService extends PaymentService { - private final MobilePayTokenService tokenService; - - private final OrderService orderService; - private final PaymentRepository paymentRepository; - - private final RestTemplate restTemplate; - private final WebhookConfigRepository webhookConfigRepository; - - @Value("${mobilepay.subscription-key}") - private String subscriptionKey; - - @Value("${mobilepay.merchant-id}") - private String merchantId; - - @Value("${mobilepay.system-name}") - private String systemName; - - @Value("${mobilepay.api-url}") - private String apiUrl; - - @Value("${commercify.host}") - private String host; - - private static final String PROVIDER_NAME = "MOBILEPAY"; - - public MobilePayService(PaymentRepository paymentRepository, EmailService emailService, OrderService orderService, MobilePayTokenService tokenService, OrderService orderService1, PaymentRepository paymentRepository1, RestTemplate restTemplate, WebhookConfigRepository webhookConfigRepository) { - super(paymentRepository, emailService, orderService); - this.tokenService = tokenService; - this.orderService = orderService1; - this.paymentRepository = paymentRepository1; - this.restTemplate = restTemplate; - this.webhookConfigRepository = webhookConfigRepository; - } - - @Override - public void capturePayment(Long orderId, double captureAmount, boolean isPartialCapture) { - PaymentEntity payment = paymentRepository.findByOrderId(orderId) - .orElseThrow(() -> new RuntimeException("Payment not found for orderId: " + orderId)); - - if (payment.getStatus() != PaymentStatus.PAID) { - throw new RuntimeException("Payment cannot captured"); - } - - OrderDetailsDTO order = orderService.getOrderById(payment.getOrderId()); - - double capturingAmount = isPartialCapture ? captureAmount : payment.getTotalAmount(); - capturingAmount *= 100; // Convert to minor units - - log.info("Capturing payment: orderId={}, amount={} formatted={}", orderId, capturingAmount, Math.round(capturingAmount)); - - // Capture payment - if (payment.getMobilePayReference() != null) { - capturePayment(payment.getMobilePayReference(), Math.round(capturingAmount), order.getOrder().getCurrency()); - } - - // Update payment status - payment.setStatus(PaymentStatus.PAID); - paymentRepository.save(payment); - } - - @Transactional - public PaymentResponse initiatePayment(PaymentRequest request) { - try { - OrderDetailsDTO orderDetails = orderService.getOrderById(request.orderId()); - // Create MobilePay payment request - Map paymentRequest = createMobilePayRequest(orderDetails.getOrder(), request); - - // Call MobilePay API - MobilePayCheckoutResponse mobilePayCheckoutResponse = createMobilePayPayment(paymentRequest); - - // Create and save payment entity - PaymentEntity payment = PaymentEntity.builder() - .orderId(orderDetails.getOrder().getId()) - .totalAmount(orderDetails.getOrder().getTotal()) - .paymentProvider(PaymentProvider.MOBILEPAY) - .status(PaymentStatus.PENDING) - .paymentMethod(request.paymentMethod()) // 'WALLET' or 'CARD' - .mobilePayReference(mobilePayCheckoutResponse.reference()) - .build(); - - PaymentEntity savedPayment = paymentRepository.save(payment); - - return new PaymentResponse( - savedPayment.getId(), - savedPayment.getStatus(), - mobilePayCheckoutResponse.redirectUrl() - ); - } catch (Exception e) { - log.error("Error creating MobilePay payment", e); - throw new PaymentProcessingException("Failed to create MobilePay payment", e); - } - } - - @Transactional - public void handlePaymentCallback(WebhookPayload payload) { - PaymentEntity payment = paymentRepository.findByMobilePayReference(payload.reference()) - .orElseThrow(() -> new PaymentProcessingException("Payment not found", null)); - - PaymentStatus newStatus = mapMobilePayStatus(payload.name()); - - // Update payment status and trigger confirmation email if needed - handlePaymentStatusUpdate(payment.getOrderId(), newStatus); - } - - private HttpHeaders mobilePayRequestHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - headers.set("Authorization", "Bearer " + tokenService.getAccessToken()); - headers.set("Ocp-Apim-Subscription-Key", subscriptionKey); - headers.set("Merchant-Serial-Number", merchantId); - headers.set("Vipps-System-Name", systemName); - headers.set("Vipps-System-Version", "1.0"); - headers.set("Vipps-System-Plugin-Name", "commercify"); - headers.set("Vipps-System-Plugin-Version", "1.0"); - headers.set("Idempotency-Key", UUID.randomUUID().toString()); - return headers; - } - - @Retryable( - notRecoverable = {PaymentProcessingException.class}, - backoff = @Backoff(delay = 1000) - ) - private MobilePayCheckoutResponse createMobilePayPayment(Map request) { - HttpHeaders headers = mobilePayRequestHeaders(); - HttpEntity> entity = new HttpEntity<>(request, headers); - - try { - ResponseEntity response = restTemplate.exchange( - apiUrl + "/epayment/v1/payments", - HttpMethod.POST, - entity, - MobilePayCheckoutResponse.class - ); - - if (response.getBody() == null) { - throw new PaymentProcessingException("No response from MobilePay API", null); - } - - return response.getBody(); - } catch (Exception e) { - log.error("Error creating MobilePay payment: {}", e.getMessage()); - throw new PaymentProcessingException("Failed to create MobilePay payment", e); - } - } - - public Map createMobilePayRequest(OrderDTO order, PaymentRequest request) { - validationPaymentRequest(request); - - Map paymentRequest = new HashMap<>(); - - // Amount object - Map amount = new HashMap<>(); - String value = String.valueOf(Math.round(order.getTotal() * 100)); // Convert to minor units - amount.put("value", value); // Convert to minor units - amount.put("currency", "DKK"); - paymentRequest.put("amount", amount); - - // Payment method object - Map paymentMethod = new HashMap<>(); - paymentMethod.put("type", request.paymentMethod()); - paymentRequest.put("paymentMethod", paymentMethod); - - // Customer object - Map customer = new HashMap<>(); - customer.put("phoneNumber", request.phoneNumber()); - paymentRequest.put("customer", customer); - - // Other fields - String reference = String.join("-", merchantId, systemName, String.valueOf(order.getId()), value); - paymentRequest.put("reference", reference); - paymentRequest.put("returnUrl", request.returnUrl() + "?orderId=" + order.getId()); - paymentRequest.put("userFlow", "WEB_REDIRECT"); - paymentRequest.put("paymentDescription", "Order Number #" + order.getId()); - - return paymentRequest; - } - - private void validationPaymentRequest(PaymentRequest request) { - List errors = new ArrayList<>(); - - if (!Objects.equals(request.currency(), "DKK")) { - errors.add("Currency must be DKK"); - } - - if (request.paymentMethod() == null || request.paymentMethod().isEmpty()) { - errors.add("Payment method is required"); - } - - if (!Objects.equals(request.paymentMethod(), "WALLET") && !Objects.equals(request.paymentMethod(), "CARD")) { - errors.add("Invalid payment method"); - } - - if (request.returnUrl() == null || request.returnUrl().isEmpty()) { - errors.add("Return URL is required"); - } - - if (!errors.isEmpty()) { - throw new PaymentProcessingException("Invalid payment request: " + String.join(", ", errors), null); - } - } - - private PaymentStatus mapMobilePayStatus(String status) { - return switch (status.toUpperCase()) { - case "CREATED" -> PaymentStatus.PENDING; - case "AUTHORIZED" -> PaymentStatus.PAID; - case "ABORTED", "CANCELLED" -> PaymentStatus.CANCELLED; - case "EXPIRED" -> PaymentStatus.EXPIRED; - case "TERMINATED" -> PaymentStatus.TERMINATED; - case "CAPTURED" -> PaymentStatus.CAPTURED; - case "REFUNDED" -> PaymentStatus.REFUNDED; - default -> throw new PaymentProcessingException("Unknown MobilePay status: " + status, null); - }; - } - - @Transactional - public void registerWebhooks(String callbackUrl) { - HttpHeaders headers = mobilePayRequestHeaders(); - - Map request = new HashMap<>(); - request.put("url", callbackUrl); - request.put("events", new String[]{ - "epayments.payment.aborted.v1", - "epayments.payment.expired.v1", - "epayments.payment.cancelled.v1", - "epayments.payment.captured.v1", - "epayments.payment.refunded.v1", - "epayments.payment.authorized.v1" - }); - - HttpEntity> entity = new HttpEntity<>(request, headers); - - try { - ResponseEntity response = restTemplate.exchange( - apiUrl + "/webhooks/v1/webhooks", - HttpMethod.POST, - entity, - WebhookRegistrationResponse.class - ); - - if (response.getBody() == null) { - throw new PaymentProcessingException("No response from MobilePay API", null); - } - - // Save or update webhook configuration - webhookConfigRepository.findByProvider(PROVIDER_NAME) - .ifPresentOrElse( - config -> { - config.setWebhookUrl(callbackUrl); - config.setWebhookSecret(response.getBody().secret()); - webhookConfigRepository.save(config); - - log.info("Webhook updated successfully"); - }, - () -> { - WebhookConfigEntity newConfig = WebhookConfigEntity.builder() - .provider(PROVIDER_NAME) - .webhookUrl(callbackUrl) - .webhookSecret(response.getBody().secret()) - .build(); - webhookConfigRepository.save(newConfig); - - log.info("Webhook registered successfully"); - } - ); - - } catch (Exception e) { - log.error("Error registering MobilePay webhooks: {}", e.getMessage()); - throw new PaymentProcessingException("Failed to register MobilePay webhooks", e); - } - } - - @Transactional(readOnly = true) - public void authenticateRequest(String date, String contentSha256, String authorization, String payload, HttpServletRequest request) { - try { -// Verify content - log.info("Verifying content"); - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8)); - String encodedHash = Base64.getEncoder().encodeToString(hash); - - if (!encodedHash.equals(contentSha256)) { - throw new SecurityException("Hash mismatch"); - } - - log.info("Content verified"); - -// Verify signature - log.info("Verifying signature"); - String path = request.getRequestURI(); - URI uri = new URI(host + path); - - String expectedSignedString = String.format("POST\n%s\n%s;%s;%s", path, date, uri.getHost(), encodedHash); - - Mac hmacSha256 = Mac.getInstance("HmacSHA256"); - - CompletableFuture secretByteArray = getWebhookSecret().thenApply(s -> s.getBytes(StandardCharsets.UTF_8)); - - SecretKeySpec secretKey = new SecretKeySpec(secretByteArray.get(), "HmacSHA256"); - hmacSha256.init(secretKey); - - byte[] hmacSha256Bytes = hmacSha256.doFinal(expectedSignedString.getBytes(StandardCharsets.UTF_8)); - String expectedSignature = Base64.getEncoder().encodeToString(hmacSha256Bytes); - String expectedAuthorization = String.format("HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=%s", expectedSignature); - - if (!authorization.equals(expectedAuthorization)) { - throw new SecurityException("Signature mismatch"); - } - - log.info("Signature verified"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 algorithm not found", e); - } catch (InvalidKeyException | URISyntaxException | ExecutionException | InterruptedException e) { - throw new RuntimeException(e); - } - } - - public void deleteWebhook(String id) { - HttpHeaders headers = mobilePayRequestHeaders(); - HttpEntity entity = new HttpEntity<>(headers); - - try { - restTemplate.exchange( - apiUrl + "/webhooks/v1/webhooks/" + id, - HttpMethod.DELETE, - entity, - Object.class); - - log.info("Webhook deleted successfully"); - } catch (Exception e) { - log.error("Error deleting MobilePay webhook: {}", e.getMessage()); - throw new RuntimeException("Failed to delete MobilePay webhook", e); - } - } - - public Object getWebhooks() { - HttpHeaders headers = mobilePayRequestHeaders(); - HttpEntity entity = new HttpEntity<>(headers); - - try { - ResponseEntity response = restTemplate.exchange( - apiUrl + "/webhooks/v1/webhooks", - HttpMethod.GET, - entity, - Object.class); - - return response.getBody(); - } catch (Exception e) { - log.error("Error getting MobilePay webhooks: {}", e.getMessage()); - throw new RuntimeException("Failed to get MobilePay webhooks", e); - } - } - - @Async - protected CompletableFuture getWebhookSecret() { - try { - final String secret = webhookConfigRepository.findByProvider(PROVIDER_NAME) - .map(WebhookConfigEntity::getWebhookSecret) - .orElseThrow(() -> new PaymentProcessingException("Webhook secret not found", null)); - - return CompletableFuture.completedFuture(secret); - } catch (Exception e) { - log.error("Error getting webhook secret: {}", e.getMessage()); - return CompletableFuture.failedFuture(e); - } - } - - public void capturePayment(String mobilePayReference, long captureAmount, String currency) { - paymentRepository.findByMobilePayReference(mobilePayReference) - .orElseThrow(() -> new PaymentProcessingException("Payment not found", null)); - - HttpHeaders headers = mobilePayRequestHeaders(); - - Map request = new HashMap<>(); - request.put("modificationAmount", new MobilePayPrice(captureAmount, currency)); - - HttpEntity> entity = new HttpEntity<>(request, headers); - - try { - restTemplate.exchange( - apiUrl + "/epayment/v1/payments/" + mobilePayReference + "/capture", - HttpMethod.POST, - entity, - Object.class); - } catch (Exception e) { - log.error("Error capturing MobilePay payment: {}", e.getMessage()); - throw new PaymentProcessingException("Failed to capture MobilePay payment", e); - } - } -} - -record MobilePayPrice( - long value, - String currency -) { -} - -record MobilePayCheckoutResponse( - String redirectUrl, - String reference -) { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeConfig.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeConfig.java deleted file mode 100644 index 1bd6363..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.stripe; - -import com.stripe.Stripe; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class StripeConfig { - - @Value("${stripe.secret-test-key}") - private String stripeApiKey; - - @Value("${stripe.webhook-secret}") - private String webhookSecret; - - @PostConstruct - public void init() { - Stripe.apiKey = stripeApiKey; - } - - @Bean - public String stripeWebhookSecret() { - return webhookSecret; - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeController.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeController.java deleted file mode 100644 index 6e8f59c..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.stripe; - -import com.zenfulcode.commercify.commercify.api.requests.PaymentRequest; -import com.zenfulcode.commercify.commercify.api.responses.PaymentResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/payments/stripe") -@RequiredArgsConstructor -public class StripeController { - private final StripeService stripeService; - private final StripeWebhookHandler webhookHandler; - - @PostMapping("/create") - public ResponseEntity createPayment(@RequestBody PaymentRequest request) { - try { - PaymentResponse response = stripeService.initiatePayment(request); - return ResponseEntity.ok(response); - } catch (Exception e) { - return ResponseEntity.badRequest().body(PaymentResponse.FailedPayment()); - } - } - - @PostMapping("/webhook") - public ResponseEntity handleWebhook( - @RequestBody String payload, - @RequestHeader("Stripe-Signature") String sigHeader) { - webhookHandler.handleWebhookEvent(payload, sigHeader); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeService.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeService.java deleted file mode 100644 index d45d57f..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.stripe; - -import com.stripe.exception.StripeException; -import com.stripe.model.checkout.Session; -import com.stripe.param.checkout.SessionCreateParams; -import com.zenfulcode.commercify.commercify.PaymentProvider; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.api.requests.PaymentRequest; -import com.zenfulcode.commercify.commercify.api.responses.PaymentResponse; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import com.zenfulcode.commercify.commercify.entity.VariantOptionEntity; -import com.zenfulcode.commercify.commercify.exception.OrderNotFoundException; -import com.zenfulcode.commercify.commercify.exception.PaymentProcessingException; -import com.zenfulcode.commercify.commercify.repository.OrderRepository; -import com.zenfulcode.commercify.commercify.repository.PaymentRepository; -import com.zenfulcode.commercify.commercify.service.product.ProductService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeService { - private final OrderRepository orderRepository; - private final PaymentRepository paymentRepository; - private final ProductService productService; - - @Transactional - public PaymentResponse initiatePayment(PaymentRequest request) { - try { - OrderEntity order = orderRepository.findById(request.orderId()) - .orElseThrow(() -> new OrderNotFoundException(request.orderId())); - - Session session = createCheckoutSession(order, request); - - PaymentEntity payment = PaymentEntity.builder() - .orderId(order.getId()) - .totalAmount(order.getTotal()) - .paymentProvider(PaymentProvider.STRIPE) - .status(PaymentStatus.PENDING) - .paymentMethod(request.paymentMethod()) - .build(); - - PaymentEntity savedPayment = paymentRepository.save(payment); - - return new PaymentResponse( - savedPayment.getId(), - savedPayment.getStatus(), - session.getUrl() - ); - } catch (Exception e) { - log.error("Error creating Stripe checkout session", e); - throw new PaymentProcessingException("Failed to create Stripe checkout session", e); - } - } - - private Session createCheckoutSession(OrderEntity order, PaymentRequest request) throws StripeException { - List lineItems = new ArrayList<>(); - - for (OrderLineEntity line : order.getOrderLines()) { - ProductDTO product = productService.getProductById(line.getProductId()); - SessionCreateParams.LineItem.PriceData.ProductData.Builder productDataBuilder = SessionCreateParams.LineItem.PriceData.ProductData.builder() - .setName(product.getName()) - .setDescription(product.getDescription()) - .addImage(product.getImageUrl()); - - if (line.getProductVariant() != null) { - StringBuilder variantInfo = new StringBuilder(); - for (VariantOptionEntity option : line.getProductVariant().getOptions()) { - variantInfo.append(option.getName()) - .append(": ") - .append(option.getValue()) - .append(", "); - } - if (!variantInfo.isEmpty()) { - variantInfo.setLength(variantInfo.length() - 2); - productDataBuilder.putMetadata("variant", variantInfo.toString()); - } - } - - lineItems.add(SessionCreateParams.LineItem.builder() - .setPriceData(SessionCreateParams.LineItem.PriceData.builder() - .setCurrency(order.getCurrency().toLowerCase()) - .setUnitAmount((long) (line.getUnitPrice() * 100)) - .setProductData(productDataBuilder.build()) - .build()) - .setQuantity((long) line.getQuantity()) - .build()); - } - - SessionCreateParams params = SessionCreateParams.builder() - .setMode(SessionCreateParams.Mode.PAYMENT) - .setSuccessUrl(request.returnUrl() + "?success=true&orderId=" + order.getId()) - .setCancelUrl(request.returnUrl() + "?success=false&orderId=" + order.getId()) - .putMetadata("orderId", order.getId().toString()) - .addAllLineItem(lineItems) - .build(); - - return Session.create(params); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeWebhookHandler.java b/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeWebhookHandler.java deleted file mode 100644 index 0859d90..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/stripe/StripeWebhookHandler.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.zenfulcode.commercify.commercify.integration.stripe; - -import com.stripe.exception.SignatureVerificationException; -import com.stripe.model.Event; -import com.stripe.model.checkout.Session; -import com.stripe.net.Webhook; -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import com.zenfulcode.commercify.commercify.exception.OrderNotFoundException; -import com.zenfulcode.commercify.commercify.exception.PaymentProcessingException; -import com.zenfulcode.commercify.commercify.repository.OrderRepository; -import com.zenfulcode.commercify.commercify.repository.PaymentRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeWebhookHandler { - private final PaymentRepository paymentRepository; - private final OrderRepository orderRepository; - private final String stripeWebhookSecret; - - @Transactional - public void handleWebhookEvent(String payload, String sigHeader) { - Event event; - try { - event = Webhook.constructEvent(payload, sigHeader, stripeWebhookSecret); - } catch (SignatureVerificationException e) { - throw new PaymentProcessingException("Invalid signature", e); - } - - if ("checkout.session.completed".equals(event.getType())) { - Session session = (Session) event.getDataObjectDeserializer().getObject().get(); - handleSuccessfulPayment(session); - } - } - - private void handleSuccessfulPayment(Session session) { - String orderId = session.getMetadata().get("orderId"); - if (orderId == null) { - log.error("No order ID in session metadata"); - return; - } - - OrderEntity order = orderRepository.findById(Long.parseLong(orderId)) - .orElseThrow(() -> new OrderNotFoundException(Long.parseLong(orderId))); - - // Update order status - order.setStatus(OrderStatus.PAID); - orderRepository.save(order); - - // Update payment status - PaymentEntity payment = paymentRepository.findByOrderId(order.getId()) - .orElseThrow(() -> new RuntimeException("Payment not found for order: " + orderId)); - payment.setStatus(PaymentStatus.PAID); - paymentRepository.save(payment); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/AddressRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/AddressRepository.java deleted file mode 100644 index b9ee77e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/AddressRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface AddressRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/ConfirmationTokenRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/ConfirmationTokenRepository.java deleted file mode 100644 index 22c6116..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/ConfirmationTokenRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.ConfirmationTokenEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface ConfirmationTokenRepository extends JpaRepository { - Optional findByToken(String token); - Optional findByUserIdAndConfirmedFalse(Long userId); -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderLineRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderLineRepository.java deleted file mode 100644 index dbc4637..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderLineRepository.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.Collection; -import java.util.List; -import java.util.Set; - -@Repository -public interface OrderLineRepository extends JpaRepository { - @Query("SELECT DISTINCT ol.order FROM OrderLineEntity ol " + - "WHERE ol.productId = :productId " + - "AND ol.order.status IN :statuses") - Set findActiveOrdersForProduct( - @Param("productId") Long productId, - @Param("statuses") Collection statuses - ); - - @Query("SELECT DISTINCT ol.order FROM OrderLineEntity ol " + - "JOIN ol.productVariant v " + - "WHERE v.id = :variantId " + - "AND ol.order.status IN :statuses") - Set findActiveOrdersForVariant( - @Param("variantId") Long variantId, - @Param("statuses") Collection statuses - ); - - @Query("SELECT ol FROM OrderLineEntity ol " + - "WHERE ol.order.id = :orderId") - List findByOrderId(@Param("orderId") Long orderId); - - @Query("SELECT COUNT(ol) > 0 FROM OrderLineEntity ol " + - "WHERE ol.productId = :productId " + - "AND ol.order.status IN :statuses") - boolean hasActiveOrders( - @Param("productId") Long productId, - @Param("statuses") Collection statuses - ); - - @Query("SELECT COUNT(ol) > 0 FROM OrderLineEntity ol " + - "JOIN ol.productVariant v " + - "WHERE v.id = :variantId " + - "AND ol.order.status IN :statuses") - boolean hasActiveOrdersForVariant( - @Param("variantId") Long variantId, - @Param("statuses") Collection statuses - ); -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderRepository.java deleted file mode 100644 index 6115ecd..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderRepository.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -import org.springframework.web.bind.annotation.PathVariable; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -@Repository -public interface OrderRepository extends JpaRepository { - Page findByUserId(Long userId, Pageable pageable); - - boolean existsByIdAndUserId(Long id, Long userId); - - @Query("SELECT DISTINCT o FROM OrderEntity o " + - "JOIN FETCH o.orderLines ol " + - "WHERE o.id = :orderId") - Optional findByIdWithOrderLines(@PathVariable Long orderId); - - @Query("SELECT o FROM OrderEntity o " + - "WHERE o.userId = :userId " + - "AND o.status IN :statuses") - List findByUserIdAndStatusIn( - @Param("userId") Long userId, - @Param("statuses") Collection statuses - ); - - @Query("SELECT o FROM OrderEntity o " + - "WHERE o.status IN :statuses") - List findByStatusIn(@Param("statuses") Collection statuses); - - @Query("SELECT COUNT(o) > 0 FROM OrderEntity o " + - "WHERE o.status IN :statuses " + - "AND o.userId = :userId") - boolean hasOrdersInStatus( - @Param("userId") Long userId, - @Param("statuses") Collection statuses - ); -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderShippingInfoRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderShippingInfoRepository.java deleted file mode 100644 index 1785ee5..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/OrderShippingInfoRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.OrderShippingInfo; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface OrderShippingInfoRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/PaymentRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/PaymentRepository.java deleted file mode 100644 index 5fe09fb..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/PaymentRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface PaymentRepository extends JpaRepository { - Optional findByOrderId(Long orderId); - - Optional findByMobilePayReference(String mobilePayReference); -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductRepository.java deleted file mode 100644 index e9f1665..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ProductRepository extends JpaRepository { - Page queryAllByActiveTrue(Pageable pageable); -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductVariantRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductVariantRepository.java deleted file mode 100644 index aedd2de..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/ProductVariantRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductVariantRepository extends JpaRepository { - Page findByProductId(Long productId, Pageable pageable); -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/repository/UserRepository.java b/src/main/java/com/zenfulcode/commercify/commercify/repository/UserRepository.java deleted file mode 100644 index fb08fa2..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/repository/UserRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.zenfulcode.commercify.commercify.repository; - -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/AuthenticationService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/AuthenticationService.java deleted file mode 100644 index 24457e9..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/AuthenticationService.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - - -import com.zenfulcode.commercify.commercify.api.requests.LoginUserRequest; -import com.zenfulcode.commercify.commercify.api.requests.RegisterUserRequest; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.UserMapper; -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -@AllArgsConstructor -@Slf4j -public class AuthenticationService { - private final UserRepository userRepository; - private final AuthenticationManager authenticationManager; - private final UserMapper mapper; - private final JwtService jwtService; - private final PasswordEncoder passwordEncoder; - private final UserManagementService usersService; - - @Transactional - public UserDTO registerUser(RegisterUserRequest registerRequest) { - Optional existing = userRepository.findByEmail(registerRequest.email()); - - if (existing.isPresent()) { - throw new RuntimeException("User with email " + registerRequest.email() + " already exists"); - } - - AddressEntity shippingAddress = null; - - if (registerRequest.defaultAddress() != null) { - shippingAddress = AddressEntity.builder() - .street(registerRequest.defaultAddress().getStreet()) - .city(registerRequest.defaultAddress().getCity()) - .state(registerRequest.defaultAddress().getState()) - .zipCode(registerRequest.defaultAddress().getZipCode()) - .country(registerRequest.defaultAddress().getCountry()) - .build(); - } - - UserEntity user = UserEntity.builder() - .firstName(registerRequest.firstName()) - .lastName(registerRequest.lastName()) - .email(registerRequest.email()) - .password(passwordEncoder.encode(registerRequest.password())) - .roles(List.of("USER")) - .defaultAddress(shippingAddress) - .emailConfirmed(false) - .build(); - - UserEntity savedUser = userRepository.save(user); - - // TODO: Send user confirmation email - - return mapper.apply(savedUser); - } - - @Transactional - public void convertGuestToUser(Long id, RegisterUserRequest request) { - usersService.updateUser(id, request.toUserDTO()); - - UserEntity user = userRepository.findById(id) - .orElseThrow(() -> new RuntimeException("User not found")); - - user.setPassword(passwordEncoder.encode(request.password())); - user.removeRole("GUEST"); - user.addRole("USER"); - - userRepository.save(user); - } - - public UserDTO registerGuest() { - String firstName = "Guest"; - String lastName = String.valueOf(new Date().toInstant().toEpochMilli()); - String email = firstName + lastName + "@commercify.app"; - String password = UUID.randomUUID().toString(); - - UserEntity user = UserEntity.builder() - .firstName(firstName) - .lastName(lastName) - .email(email) - .password(passwordEncoder.encode(password)) - .roles(List.of("GUEST")) - .emailConfirmed(true) - .build(); - UserEntity savedUser = userRepository.save(user); - - authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - email, - password - ) - ); - - return mapper.apply(savedUser); - } - - public UserDTO authenticate(LoginUserRequest login) { - authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - login.email(), - login.password() - ) - ); - - UserEntity user = userRepository.findByEmail(login.email()).orElseThrow(); - - if (!passwordEncoder.matches(login.password(), user.getPassword())) - return null; - - return mapper.apply(user); - } - - @Transactional(readOnly = true) - public UserDTO getAuthenticatedUser(String authHeader) { - if (!authHeader.startsWith("Bearer ")) return null; - - final String jwt = authHeader.substring(7); - final String email = jwtService.extractUsername(jwt); - - return userRepository.findByEmail(email) - .map(mapper) - .orElseThrow(); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/JwtService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/JwtService.java deleted file mode 100644 index d8b4e70..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/JwtService.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; - -@Service -public class JwtService { - @Value("${security.jwt.secret-key}") - private String secretKey; - - @Value("${security.jwt.expiration-time}") - private long jwtExpiration; - - public String extractUsername(String token) { - return extractClaim(token, Claims::getSubject); - } - - public T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); - } - - public String generateToken(UserDTO user) { - return generateToken(new HashMap<>(), user); - } - - public String generateToken(Map extraClaims, UserDTO user) { - extraClaims.put("roles", user.getRoles()); - - return buildToken(extraClaims, user); - } - - public long getExpirationTime() { - return jwtExpiration; - } - - private String buildToken( - Map extraClaims, - UserDTO user - ) { - return Jwts - .builder() - .claims(extraClaims) - .subject(user.getEmail()) - .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) - .signWith(getSignInKey()) - .compact(); - } - - public boolean isTokenValid(String token, UserDetails userDetails) { - final String username = extractUsername(token); - return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); - } - - private boolean isTokenExpired(String token) { - return extractExpiration(token).before(new Date()); - } - - private Date extractExpiration(String token) { - return extractClaim(token, Claims::getExpiration); - } - - private Claims extractAllClaims(String token) { - return Jwts - .parser() - .verifyWith(getSignInKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - private SecretKey getSignInKey() { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/PaymentService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/PaymentService.java deleted file mode 100644 index ecbd9ab..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/PaymentService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import com.zenfulcode.commercify.commercify.repository.PaymentRepository; -import com.zenfulcode.commercify.commercify.service.email.EmailService; -import com.zenfulcode.commercify.commercify.service.order.OrderService; -import jakarta.mail.MessagingException; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; - -@AllArgsConstructor -@Slf4j -public class PaymentService { - private final PaymentRepository paymentRepository; - private final EmailService emailService; - private final OrderService orderService; - - @Transactional - public void handlePaymentStatusUpdate(Long orderId, PaymentStatus newStatus) { - PaymentEntity payment = paymentRepository.findByOrderId(orderId) - .orElseThrow(() -> new RuntimeException("Payment not found for order: " + orderId)); - - PaymentStatus oldStatus = payment.getStatus(); - payment.setStatus(newStatus); - paymentRepository.save(payment); - - orderService.updateOrderStatus(orderId, newStatus); - - // If payment is successful, send confirmation email - if (newStatus == PaymentStatus.PAID && oldStatus != PaymentStatus.PAID) { - try { - // Get order details for email - OrderDetailsDTO orderDetails = orderService.getOrderById(orderId); - - // Send confirmation email - emailService.sendOrderConfirmation(orderDetails); - emailService.sendNewOrderNotification(orderDetails); - log.info("Order confirmation email sent for order: {}", orderId); - } catch (MessagingException e) { - log.error("Failed to send order confirmation email for order {}: {}", - orderId, e.getMessage()); - } - } - } - - public PaymentStatus getPaymentStatus(Long orderId) { - return paymentRepository.findByOrderId(orderId) - .map(PaymentEntity::getStatus) - .orElse(PaymentStatus.NOT_FOUND); - } - - public void capturePayment(Long paymentId, double captureAmount, boolean isPartialCapture) { - throw new UnsupportedOperationException("Capture payment is not supported yet"); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/StockManagementService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/StockManagementService.java deleted file mode 100644 index 0643320..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/StockManagementService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import com.zenfulcode.commercify.commercify.repository.ProductVariantRepository; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Set; - -@Service -@AllArgsConstructor -public class StockManagementService { - private final ProductRepository productRepository; - private final ProductVariantRepository variantRepository; - - @Transactional - public void updateStockLevels(Set orderLines) { - for (OrderLineEntity line : orderLines) { - if (line.getProductVariant() != null) { - // Update variant stock - ProductVariantEntity variant = line.getProductVariant(); - variant.setStock(variant.getStock() - line.getQuantity()); - variantRepository.save(variant); - } else { - // Update product stock - ProductEntity product = productRepository.findById(line.getProductId()) - .orElseThrow(() -> new ProductNotFoundException(line.getProductId())); - product.setStock(product.getStock() - line.getQuantity()); - productRepository.save(product); - } - } - } - - @Transactional - public void restoreStockLevels(Set orderLines) { - for (OrderLineEntity line : orderLines) { - if (line.getProductVariant() != null) { - // Restore variant stock - ProductVariantEntity variant = line.getProductVariant(); - variant.setStock(variant.getStock() + line.getQuantity()); - variantRepository.save(variant); - } else { - // Restore product stock - ProductEntity product = productRepository.findById(line.getProductId()) - .orElseThrow(() -> new ProductNotFoundException(line.getProductId())); - product.setStock(product.getStock() + line.getQuantity()); - productRepository.save(product); - } - } - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/UserManagementService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/UserManagementService.java deleted file mode 100644 index 58829ad..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/UserManagementService.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.AddressMapper; -import com.zenfulcode.commercify.commercify.dto.mapper.UserMapper; -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class UserManagementService { - private final UserRepository userRepository; - private final UserMapper mapper; - private final AddressMapper addressMapper; - - @Transactional(readOnly = true) - public UserDTO getUserById(Long id) { - UserEntity user = userRepository.findById(id) - .orElseThrow(() -> new RuntimeException("User not found")); - return mapper.apply(user); - } - - @Transactional(readOnly = true) - public Page getAllUsers(Pageable pageable) { - return userRepository.findAll(pageable).map(mapper); - } - - @Transactional - public UserDTO updateUser(Long id, UserDTO userDTO) throws RuntimeException { // Explicitly declare throws - UserEntity user = userRepository.findById(id) - .orElseThrow(() -> new RuntimeException("User not found")); - - Optional existing = userRepository.findByEmail(userDTO.getEmail()); - - if (existing.isPresent() && !existing.get().getId().equals(id)) { // Add check for same user - throw new RuntimeException("User with email " + userDTO.getEmail() + " already exists"); - } - - user.setFirstName(userDTO.getFirstName()); - user.setLastName(userDTO.getLastName()); - user.setEmail(userDTO.getEmail()); - - return mapper.apply(userRepository.save(user)); - } - - @Transactional - public void deleteUser(Long id) { - if (!userRepository.existsById(id)) { - throw new RuntimeException("User not found"); - } - userRepository.deleteById(id); - } - - @Transactional - public AddressDTO setDefaultAddress(Long userId, AddressDTO request) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - - AddressEntity address = AddressEntity.builder() - .street(request.getStreet()) - .city(request.getCity()) - .state(request.getState()) - .zipCode(request.getZipCode()) - .country(request.getCountry()) - .build(); - - user.setDefaultAddress(address); - userRepository.save(user); - - return addressMapper.apply(address); - } - - @Transactional - public UserDTO removeDefaultAddress(Long userId) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - user.setDefaultAddress(null); - return mapper.apply(userRepository.save(user)); - } - - @Transactional - public UserDTO addRoleToUser(Long userId, String role) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - - if (user.getRoles() == null) { - user.setRoles(new ArrayList<>()); - } - - if (!user.getRoles().contains(role.toUpperCase())) { - user.getRoles().add(role.toUpperCase()); - userRepository.save(user); - } - - return mapper.apply(user); - } - - @Transactional - public UserDTO removeRoleFromUser(Long userId, String role) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - - user.getRoles().remove(role.toUpperCase()); - UserEntity updatedUser = userRepository.save(user); - - return mapper.apply(updatedUser); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailConfirmationService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailConfirmationService.java deleted file mode 100644 index 5ad689e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailConfirmationService.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.email; - -import com.zenfulcode.commercify.commercify.entity.ConfirmationTokenEntity; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.ConfirmationTokenRepository; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import jakarta.mail.MessagingException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; - -@Service -@RequiredArgsConstructor -public class EmailConfirmationService { - private final ConfirmationTokenRepository tokenRepository; - private final UserRepository userRepository; - private final EmailService emailService; - - @Transactional - public void createConfirmationToken(UserEntity user) { - // Delete any existing unconfirmed tokens - tokenRepository.findByUserIdAndConfirmedFalse(user.getId()) - .ifPresent(tokenRepository::delete); - - // Create new token - ConfirmationTokenEntity token = ConfirmationTokenEntity.builder() - .user(user) - .confirmed(false) - .build(); - - ConfirmationTokenEntity savedToken = tokenRepository.save(token); - - try { - emailService.sendConfirmationEmail(user.getEmail(), savedToken.getToken()); - } catch (MessagingException e) { - throw new RuntimeException("Failed to send confirmation email", e); - } - } - - @Transactional - public boolean confirmEmail(String token) { - ConfirmationTokenEntity confirmationToken = tokenRepository.findByToken(token) - .orElseThrow(() -> new RuntimeException("Invalid confirmation token")); - - if (confirmationToken.isConfirmed()) { - throw new RuntimeException("Email already confirmed"); - } - - if (Instant.now().isAfter(confirmationToken.getExpiryDate())) { - throw new RuntimeException("Confirmation token expired"); - } - - confirmationToken.setConfirmed(true); - UserEntity user = confirmationToken.getUser(); - user.setEmailConfirmed(true); - - tokenRepository.save(confirmationToken); - userRepository.save(user); - - return true; - } - - @Transactional - public void resendConfirmationEmail(Long userId) { - UserEntity user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - - if (user.isEnabled()) { - throw new RuntimeException("Email already confirmed"); - } - - createConfirmationToken(user); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailService.java deleted file mode 100644 index fffd7b3..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/email/EmailService.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.email; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.service.UserManagementService; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.thymeleaf.TemplateEngine; -import org.thymeleaf.context.Context; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailService { - private final JavaMailSender mailSender; - private final TemplateEngine templateEngine; - private final UserManagementService userService; - - @Value("${spring.mail.username}") - private String fromEmail; - - @Value("${app.frontend-url}") - private String frontendUrl; - - @Value("${admin.order-email}") - private String orderEmailReceiver; - - @Async - public void sendConfirmationEmail(String to, String token) throws MessagingException { - MimeMessage mimeMessage = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); - - Context context = new Context(); - context.setVariable("confirmationUrl", - frontendUrl + "/confirm-email?token=" + token); - - String htmlContent = templateEngine.process("confirmation-email", context); - - helper.setFrom(fromEmail); - helper.setTo(to); - helper.setSubject("Confirm your email address"); - helper.setText(htmlContent, true); - - mailSender.send(mimeMessage); - } - - @Async - public void sendOrderConfirmation(OrderDetailsDTO orderDetails) throws MessagingException { - OrderDTO order = orderDetails.getOrder(); - - Context context = new Context(); - context.setVariable("order", createOrderContext(orderDetails)); - - String template = "order-confirmation-email"; - String subject = String.format("Order Confirmation #%d - %s", - order.getId(), order.getOrderStatus()); - - String receivingEmail = orderDetails.getCustomerDetails().getEmail(); - - sendTemplatedEmail(receivingEmail, subject, template, context); - log.info("Order confirmation sent to {}", receivingEmail); - } - - @Async - public void sendNewOrderNotification(OrderDetailsDTO orderDetails) throws MessagingException { - OrderDTO order = orderDetails.getOrder(); - - Context context = new Context(); - context.setVariable("order", createOrderContext(orderDetails)); - context.setVariable("dashboardUrl", frontendUrl + "/admin/orders/" + order.getId()); - - String template = "new-order-notification-email"; - String subject = String.format("New Order Received - #%d", order.getId()); - - sendTemplatedEmail(orderEmailReceiver, subject, template, context); - log.info("New order notification sent to {}", orderEmailReceiver); - } - - @Async - public void sendOrderStatusUpdate(OrderDetailsDTO orderDetails) throws MessagingException { - OrderDTO order = orderDetails.getOrder(); - UserDTO user = userService.getUserById(order.getUserId()); - - Context context = new Context(); - context.setVariable("order", createOrderContext(orderDetails)); - - String template = "order-confirmation-email"; - String subject = String.format("Order #%d Status Update - %s", - order.getId(), order.getOrderStatus()); - - sendTemplatedEmail(user.getEmail(), subject, template, context); - } - - private void sendTemplatedEmail(String to, String subject, String template, Context context) - throws MessagingException { - MimeMessage mimeMessage = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); - - String htmlContent = templateEngine.process(template, context); - - helper.setFrom(fromEmail); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(htmlContent, true); - - mailSender.send(mimeMessage); - } - - private Map createOrderContext(OrderDetailsDTO orderDetails) { - OrderDTO order = orderDetails.getOrder(); - Map orderContext = new HashMap<>(); - - orderContext.put("id", order.getId()); - orderContext.put("status", order.getOrderStatus()); - orderContext.put("createdAt", order.getCreatedAt()); - orderContext.put("currency", order.getCurrency()); - orderContext.put("subTotal", order.getSubTotal() + " " + order.getCurrency()); - orderContext.put("totalPrice", order.getTotal() + " " + order.getCurrency()); - orderContext.put("shippingCost", order.getShippingCost() + " " + order.getCurrency()); - orderContext.put("customerName", orderDetails.getCustomerDetails().getFullName()); - orderContext.put("customerEmail", orderDetails.getCustomerDetails().getEmail()); - orderContext.put("customerPhone", orderDetails.getCustomerDetails().getPhone()); - - // Add shipping and billing addresses - orderContext.put("shippingAddress", orderDetails.getShippingAddress()); - orderContext.put("billingAddress", orderDetails.getBillingAddress()); - - // Transform order lines into a format suitable for the template - List> items = orderDetails.getOrderLines().stream() - .map(line -> { - Map item = new HashMap<>(); - item.put("name", line.getProduct().getName()); - item.put("quantity", line.getQuantity()); - item.put("unitPrice", line.getUnitPrice() + " " + order.getCurrency()); - item.put("totalPrice", (line.getQuantity() * line.getUnitPrice()) + " " + order.getCurrency()); - - if (line.getVariant() != null) { - String variantDetails = line.getVariant().getOptions().stream() - .map(opt -> opt.getName() + ": " + opt.getValue()) - .collect(Collectors.joining(", ")); - item.put("variant", variantDetails); - } else { - item.put("variant", ""); - } - - return item; - }) - .collect(Collectors.toList()); - - orderContext.put("items", items); - - if (order.getOrderStatus() == OrderStatus.SHIPPED) { - // Add tracking URL if available - orderContext.put("trackingUrl", frontendUrl + "/orders/" + order.getId() + "/tracking"); - } - - return orderContext; - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderCalculationService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderCalculationService.java deleted file mode 100644 index b304745..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderCalculationService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.order; - -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.Set; - -@Service -@AllArgsConstructor -class OrderCalculationService { - public double calculateTotalAmount(Set orderLines) { - return orderLines.stream() - .mapToDouble(line -> line.getUnitPrice() * line.getQuantity()) - .sum(); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderService.java deleted file mode 100644 index 4cc915a..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderService.java +++ /dev/null @@ -1,302 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.order; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderLineRequest; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderRequest; -import com.zenfulcode.commercify.commercify.dto.*; -import com.zenfulcode.commercify.commercify.dto.mapper.OrderMapper; -import com.zenfulcode.commercify.commercify.dto.mapper.ProductMapper; -import com.zenfulcode.commercify.commercify.dto.mapper.ProductVariantMapper; -import com.zenfulcode.commercify.commercify.entity.*; -import com.zenfulcode.commercify.commercify.exception.OrderNotFoundException; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.repository.OrderRepository; -import com.zenfulcode.commercify.commercify.repository.OrderShippingInfoRepository; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import com.zenfulcode.commercify.commercify.repository.ProductVariantRepository; -import com.zenfulcode.commercify.commercify.service.StockManagementService; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Service -@AllArgsConstructor -@Slf4j -public class OrderService { - private final OrderRepository orderRepository; - private final ProductRepository productRepository; - private final OrderMapper orderMapper; - private final ProductMapper productMapper; - private final OrderValidationService validationService; - private final OrderCalculationService calculationService; - private final StockManagementService stockService; - private final ProductVariantRepository variantRepository; - private final ProductVariantMapper productVariantMapper; - private final OrderShippingInfoRepository orderShippingInfoRepository; - - @Transactional - public OrderDTO createOrder(CreateOrderRequest request) { - return createOrder(null, request); - } - - @Transactional - public OrderDTO createOrder(Long userId, CreateOrderRequest request) { - // Validate request and check stock - validationService.validateCreateOrderRequest(request); - - // Get and validate all products and variants upfront - Map products = getAndValidateProducts(request.orderLines()); - Map variants = getAndValidateVariants(request.orderLines()); - - // Get shipping information - OrderShippingInfo shippingInfo = getShippingInformation(request); - orderShippingInfoRepository.save(shippingInfo); - - // Create order entity - OrderEntity order = buildOrderEntity(userId, request, products, variants, shippingInfo); - OrderEntity savedOrder = orderRepository.save(order); - - return orderMapper.apply(savedOrder); - } - - private OrderShippingInfo getShippingInformation(CreateOrderRequest request) { - AddressDTO shippingAddress = request.shippingAddress(); - - OrderShippingInfo.OrderShippingInfoBuilder shippingInfo = OrderShippingInfo.builder() - .shippingStreet(shippingAddress.getStreet()) - .shippingCity(shippingAddress.getCity()) - .shippingState(shippingAddress.getState()) - .shippingZip(shippingAddress.getZipCode()) - .shippingCountry(shippingAddress.getCountry()); - - AddressDTO billingAddress = request.billingAddress(); - if (billingAddress != null) { - shippingInfo.billingStreet(billingAddress.getStreet()) - .billingCity(billingAddress.getCity()) - .billingState(billingAddress.getState()) - .billingZip(billingAddress.getZipCode()) - .billingCountry(billingAddress.getCountry()); - } - - shippingInfo.customerEmail(request.customerDetails().getEmail()) - .customerFirstName(request.customerDetails().getFirstName()) - .customerLastName(request.customerDetails().getLastName()) - .customerPhone(request.customerDetails().getPhone()); - - return shippingInfo.build(); - } - - @Transactional - public void updateOrderStatus(Long orderId, OrderStatus newStatus) { - OrderEntity order = findOrderById(orderId); - OrderStatus oldStatus = order.getStatus(); - - validationService.validateStatusTransition(oldStatus, newStatus); - order.setStatus(newStatus); - - orderRepository.save(order); - } - - @Transactional - public void updateOrderStatus(Long orderId, PaymentStatus paymentStatus) { - OrderEntity order = findOrderById(orderId); - OrderStatus oldStatus = order.getStatus(); - OrderStatus newStatus = validationService.mapOrderStatus(paymentStatus); - - validationService.validateStatusTransition(oldStatus, newStatus); - order.setStatus(newStatus); - - orderRepository.save(order); - } - - @Transactional - public void cancelOrder(Long orderId) { - OrderEntity order = findOrderById(orderId); - validationService.validateOrderCancellation(order); - - // Restore stock levels - stockService.restoreStockLevels(order.getOrderLines()); - - order.setStatus(OrderStatus.CANCELLED); - orderRepository.save(order); - } - - @Transactional(readOnly = true) - public Page getOrdersByUserId(Long userId, Pageable pageable) { - return orderRepository.findByUserId(userId, pageable).map(orderMapper); - } - - @Transactional(readOnly = true) - public Page getAllOrders(Pageable pageable) { - return orderRepository.findAll(pageable).map(orderMapper); - } - - @Transactional(readOnly = true) - public OrderDetailsDTO getOrderById(Long orderId) { - OrderEntity order = findOrderById(orderId); - return buildOrderDetailsDTO(order); - } - - @Transactional(readOnly = true) - public boolean isOrderOwnedByUser(Long orderId, Long userId) { - return orderRepository.existsByIdAndUserId(orderId, userId); - } - - private Map getAndValidateVariants(List orderLines) { - Set variantIds = orderLines.stream() - .map(CreateOrderLineRequest::variantId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - - if (variantIds.isEmpty()) { - return Collections.emptyMap(); - } - - Map variants = variantRepository - .findAllById(variantIds).stream().collect(Collectors.toMap(ProductVariantEntity::getId, Function.identity())); - - // Validate all requested variants exist and have sufficient stock - orderLines.forEach(line -> { - if (line.variantId() != null) { - ProductVariantEntity variant = variants.get(line.variantId()); - if (variant == null) { - throw new ProductNotFoundException(line.variantId()); - } - - // TODO - Validate stock levels - - if (!variant.getProduct().getId().equals(line.productId())) { - throw new IllegalArgumentException( - String.format("Variant %d does not belong to product %d", - line.variantId(), line.productId()) - ); - } - } - }); - - return variants; - } - - private OrderEntity buildOrderEntity(Long userId, - CreateOrderRequest request, - Map products, - Map variants, - OrderShippingInfo shippingInfo) { - // Create order lines first - Set orderLines = request.orderLines().stream() - .map(line -> createOrderLine(line.quantity(), products.get(line.productId()), - line.variantId() != null ? variants.get(line.variantId()) : null)) - .collect(Collectors.toSet()); - - double subTotal = calculationService.calculateTotalAmount(orderLines); - - OrderEntity order = OrderEntity.builder() - .userId(userId) - .orderLines(orderLines) - .status(OrderStatus.PENDING) - .currency(request.currency()) - .subTotal(subTotal) - .shippingCost(request.shippingCost()) - .orderShippingInfo(shippingInfo) - .build(); - - // Set up bidirectional relationship - orderLines.forEach(line -> line.setOrder(order)); - - return order; - } - - private OrderLineEntity createOrderLine( - int quantity, - ProductEntity product, - ProductVariantEntity variant) { - - double unitPrice = variant != null && variant.getUnitPrice() != null ? variant.getUnitPrice() : product.getUnitPrice(); - - return OrderLineEntity.builder() - .productId(product.getId()) - .productVariant(variant) - .quantity(quantity) - .unitPrice(unitPrice) - .currency(product.getCurrency()) - .build(); - } - - private Map getAndValidateProducts(List orderLines) { - Set productIds = orderLines.stream().map(CreateOrderLineRequest::productId).collect(Collectors.toSet()); - - Map products = productRepository.findAllById(productIds).stream().collect(Collectors.toMap(ProductEntity::getId, Function.identity())); - - orderLines.forEach(line -> { - ProductEntity product = products.get(line.productId()); - if (product == null) { - throw new ProductNotFoundException(line.productId()); - } - if (!product.getActive()) { - throw new IllegalArgumentException("Product is not active: " + line.productId()); - } - }); - - return products; - } - - private OrderEntity findOrderById(Long orderId) { - return orderRepository.findById(orderId).orElseThrow(() -> new OrderNotFoundException(orderId)); - } - - private OrderDetailsDTO buildOrderDetailsDTO(OrderEntity order) { - List orderLines = order.getOrderLines().stream() - .map(line -> { - ProductEntity product = productRepository.findById(line.getProductId()) - .orElseThrow(() -> new ProductNotFoundException(line.getProductId())); - - return OrderLineDTO.builder() - .id(line.getId()) - .productId(line.getProductId()) - .quantity(line.getQuantity()) - .unitPrice(line.getUnitPrice()) - .currency(line.getCurrency()) - .product(productMapper.apply(product)) - .variant(line.getProductVariant() != null ? - productVariantMapper.apply(line.getProductVariant()) : null) - .build(); - }) - .collect(Collectors.toList()); - - OrderDTO orderDTO = orderMapper.apply(order); - - OrderShippingInfo shippingInfo = order.getOrderShippingInfo(); - - CustomerDetailsDTO customerDetails = CustomerDetailsDTO.builder() - .email(shippingInfo.getCustomerEmail()) - .firstName(shippingInfo.getCustomerFirstName()) - .lastName(shippingInfo.getCustomerLastName()) - .build(); - - AddressDTO shippingAddress = AddressDTO.builder() - .city(shippingInfo.getShippingCity()) - .country(shippingInfo.getShippingCountry()) - .state(shippingInfo.getShippingState()) - .street(shippingInfo.getShippingStreet()) - .zipCode(shippingInfo.getShippingZip()) - .build(); - - AddressDTO billingAddress = AddressDTO.builder() - .city(shippingInfo.getBillingCity()) - .country(shippingInfo.getBillingCountry()) - .state(shippingInfo.getBillingState()) - .street(shippingInfo.getBillingStreet()) - .zipCode(shippingInfo.getBillingZip()) - .build(); - - return new OrderDetailsDTO(orderDTO, orderLines, customerDetails, shippingAddress, billingAddress); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderValidationService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderValidationService.java deleted file mode 100644 index 314291b..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/order/OrderValidationService.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.order; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderRequest; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.exception.InsufficientStockException; -import com.zenfulcode.commercify.commercify.exception.OrderValidationException; -import com.zenfulcode.commercify.commercify.flow.OrderStateFlow; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@AllArgsConstructor -public class OrderValidationService { - private OrderStateFlow orderStateFlow; - - public void validateCreateOrderRequest(CreateOrderRequest request) { - List errors = new ArrayList<>(); - - if (request.orderLines() == null || request.orderLines().isEmpty()) { - errors.add("Order must contain at least one item"); - } else { - request.orderLines().forEach(line -> { - if (line.quantity() <= 0) { - errors.add("Quantity must be greater than 0 for product: " + line.productId()); - } - }); - } - if (request.currency() == null || request.currency().isBlank()) { - errors.add("Currency is required"); - } - - if (request.shippingAddress() == null) { - errors.add("Shipping address is required"); - } - - if (request.billingAddress() != null) { - if (request.billingAddress().getStreet() == null || request.billingAddress().getStreet().isBlank()) { - errors.add("Billing address street is required"); - } - if (request.billingAddress().getCity() == null || request.billingAddress().getCity().isBlank()) { - errors.add("Billing address city is required"); - } - - if (request.billingAddress().getZipCode() == null || request.billingAddress().getZipCode().isBlank()) { - errors.add("Billing address zip code is required"); - } - if (request.billingAddress().getCountry() == null || request.billingAddress().getCountry().isBlank()) { - errors.add("Billing address country is required"); - } - } - - if (request.customerDetails() == null) { - errors.add("Customer details are required"); - } - - if (request.customerDetails() != null) { - if (request.customerDetails().getFirstName() == null || request.customerDetails().getFirstName().isBlank()) { - errors.add("Customer first name is required"); - } - if (request.customerDetails().getLastName() == null || request.customerDetails().getLastName().isBlank()) { - errors.add("Customer last name is required"); - } - if (request.customerDetails().getEmail() == null || request.customerDetails().getEmail().isBlank()) { - errors.add("Customer email is required"); - } - } - - if (!errors.isEmpty()) { - throw new OrderValidationException("Order validation failed: " + String.join(", ", errors)); - } - } - - public void validateStatusTransition(OrderStatus currentStatus, OrderStatus newStatus) { - if (!orderStateFlow.canTransition(currentStatus, newStatus)) { - throw new IllegalStateException( - String.format("Invalid status transition from %s to %s", currentStatus, newStatus) - ); - } - } - - public void validateOrderCancellation(OrderEntity order) { - if (orderStateFlow.canTransition(order.getStatus(), OrderStatus.CANCELLED)) { - throw new IllegalStateException( - String.format("Cannot cancel order in status: %s", order.getStatus()) - ); - } - } - - public void validateStockAvailability(ProductEntity product, int requestedQuantity) { - if (product.getStock() < requestedQuantity) { - throw new InsufficientStockException( - String.format("Insufficient stock for product %d. Available: %d, Requested: %d", - product.getId(), product.getStock(), requestedQuantity) - ); - } - } - - public OrderStatus mapOrderStatus(PaymentStatus status) { - return switch (status) { - case PENDING -> OrderStatus.PENDING; - case PAID -> OrderStatus.PAID; - case FAILED, NOT_FOUND -> OrderStatus.FAILED; - case CAPTURED -> OrderStatus.COMPLETED; - case CANCELLED, TERMINATED, EXPIRED -> OrderStatus.CANCELLED; - case REFUNDED -> OrderStatus.REFUNDED; - }; - } - -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductDeletionService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductDeletionService.java deleted file mode 100644 index 7cd0758..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductDeletionService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.product; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.ProductDeletionValidationResult; -import com.zenfulcode.commercify.commercify.dto.mapper.OrderMapper; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.exception.ProductDeletionException; -import com.zenfulcode.commercify.commercify.repository.OrderLineRepository; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@AllArgsConstructor -public class ProductDeletionService { - private final OrderLineRepository orderLineRepository; - private final OrderMapper orderMapper; - private final ProductRepository productRepository; - - public void validateAndDelete(ProductEntity product) { - ProductDeletionValidationResult validationResult = validateDeletion(product); - - if (!validationResult.canDelete()) { - throw new ProductDeletionException( - "Cannot delete product", - validationResult.getIssues(), - validationResult.getActiveOrders() - ); - } - - productRepository.delete(product); - } - - private ProductDeletionValidationResult validateDeletion(ProductEntity product) { - List activeOrders = orderLineRepository - .findActiveOrdersForProduct( - product.getId(), - List.of(OrderStatus.PENDING, OrderStatus.PAID, OrderStatus.SHIPPED) - ) - .stream() - .map(orderMapper) - .toList(); - - List issues = new ArrayList<>(); - if (!activeOrders.isEmpty()) { - issues.add(String.format("Product has %d active orders", activeOrders.size())); - } - - return new ProductDeletionValidationResult(issues.isEmpty(), issues, activeOrders); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductService.java deleted file mode 100644 index f5a53e6..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductService.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.product; - -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; -import com.zenfulcode.commercify.commercify.dto.ProductUpdateResult; -import com.zenfulcode.commercify.commercify.dto.mapper.ProductMapper; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.factory.ProductFactory; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.Set; - -@Service -@AllArgsConstructor -@Slf4j -public class ProductService { - private final ProductRepository productRepository; - private final ProductMapper productMapper; - private final ProductFactory productFactory; - private final ProductValidationService validationService; - private final ProductVariantService variantService; - private final ProductDeletionService deletionService; - - @Transactional - public ProductDTO saveProduct(ProductRequest request) { - validationService.validateProductRequest(request); - ProductEntity product = productFactory.createFromRequest(request); - - if (request.variants() != null && !request.variants().isEmpty()) { - Set variants = variantService.createVariantsFromRequest(request.variants(), product); - product.setVariants(variants); - } - - ProductEntity savedProduct = productRepository.save(product); - return productMapper.apply(savedProduct); - } - - @Transactional - public ProductUpdateResult updateProduct(Long id, ProductRequest request) { - ProductEntity product = productRepository.findById(id) - .orElseThrow(() -> new ProductNotFoundException(id)); - - updateProductDetails(product, request); - ProductEntity savedProduct = productRepository.save(product); - - return ProductUpdateResult.withWarnings( - productMapper.apply(savedProduct), - Collections.emptyList() - ); - } - - @Transactional - public void deleteProduct(Long id) { - ProductEntity product = productRepository.findById(id) - .orElseThrow(() -> new ProductNotFoundException(id)); - - deletionService.validateAndDelete(product); - } - - @Transactional - public void reactivateProduct(Long id) { - toggleProductStatus(id, true); - } - - @Transactional - public void deactivateProduct(Long id) { - toggleProductStatus(id, false); - } - - @Transactional(readOnly = true) - public Page getAllProducts(PageRequest pageRequest) { - return productRepository.findAll(pageRequest).map(productMapper); - } - - @Transactional(readOnly = true) - public ProductDTO getProductById(Long id) { - return productRepository.findById(id) - .map(productMapper) - .orElseThrow(() -> new ProductNotFoundException(id)); - } - - @Transactional(readOnly = true) - public Page getActiveProducts(PageRequest pageRequest) { - return productRepository.queryAllByActiveTrue(pageRequest).map(productMapper); - } - - private void toggleProductStatus(Long id, boolean active) { - ProductEntity product = productRepository.findById(id) - .orElseThrow(() -> new ProductNotFoundException(id)); - - product.setActive(active); - productRepository.save(product); - } - - private void updateProductDetails(ProductEntity product, ProductRequest request) { - if (request.name() != null) product.setName(request.name()); - if (request.description() != null) product.setDescription(request.description()); - if (request.stock() != null) product.setStock(request.stock()); - if (request.active() != null) product.setActive(request.active()); - if (request.imageUrl() != null) product.setImageUrl(request.imageUrl()); - if (request.price() != null) { - product.setUnitPrice(request.price().amount()); - product.setCurrency(request.price().currency()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductValidationService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductValidationService.java deleted file mode 100644 index 843f46d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductValidationService.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.product; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductVariantRequest; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.OrderMapper; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import com.zenfulcode.commercify.commercify.exception.ProductDeletionException; -import com.zenfulcode.commercify.commercify.exception.ProductValidationException; -import com.zenfulcode.commercify.commercify.repository.OrderLineRepository; -import lombok.AllArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -@Service -@AllArgsConstructor -public class ProductValidationService { - private final OrderLineRepository orderLineRepository; - private final OrderMapper orderMapper; - - public void validateProductRequest(ProductRequest request) { - List errors = new ArrayList<>(); - - if (request.name() == null || request.name().isBlank()) { - errors.add("Product name is required"); - } - if (request.price() == null || request.price().amount() == null || request.price().amount() < 0) { - errors.add("Valid unitPrice is required"); - } - if (request.stock() != null && request.stock() < 0) { - errors.add("Stock cannot be negative"); - } - - if (!errors.isEmpty()) { - throw new ProductValidationException(errors); - } - } - - public void validateVariantRequest(ProductVariantRequest request) { - List errors = new ArrayList<>(); - - if (request.sku() == null || request.sku().isBlank()) { - errors.add("SKU is required"); - } - if (request.options() == null || request.options().isEmpty()) { - errors.add("At least one variant option is required"); - } - - if (!errors.isEmpty()) { - throw new ProductValidationException(errors); - } - } - - public void validateVariantDeletion(ProductVariantEntity variant) { - Set activeOrders = orderLineRepository.findActiveOrdersForVariant( - variant.getId(), - List.of(OrderStatus.PENDING, OrderStatus.PAID, OrderStatus.SHIPPED) - ); - - if (!activeOrders.isEmpty()) { - List activeOrderDTOs = activeOrders.stream() - .map(orderMapper) - .toList(); - - throw new ProductDeletionException( - "Cannot delete variant with active orders", - List.of("Variant has active orders"), - activeOrderDTOs - ); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductVariantService.java b/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductVariantService.java deleted file mode 100644 index 19e1f6e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/service/product/ProductVariantService.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.product; - -import com.zenfulcode.commercify.commercify.api.requests.products.CreateVariantOptionRequest; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductVariantRequest; -import com.zenfulcode.commercify.commercify.dto.ProductVariantEntityDto; -import com.zenfulcode.commercify.commercify.dto.mapper.ProductVariantMapper; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.entity.ProductVariantEntity; -import com.zenfulcode.commercify.commercify.entity.VariantOptionEntity; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import com.zenfulcode.commercify.commercify.repository.ProductVariantRepository; -import lombok.AllArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -@Service -@AllArgsConstructor -public class ProductVariantService { - private final ProductRepository productRepository; - private final ProductVariantRepository variantRepository; - private final ProductVariantMapper variantMapper; - private final ProductValidationService validationService; - - @Transactional - public ProductVariantEntityDto addVariant(Long productId, ProductVariantRequest request) { - validationService.validateVariantRequest(request); - ProductEntity product = getProduct(productId); - - ProductVariantEntity variant = createVariantFromRequest(request, product); - product.addVariant(variant); - - ProductVariantEntity savedVariant = variantRepository.save(variant); - return variantMapper.apply(savedVariant); - } - - @Transactional - public ProductVariantEntityDto updateVariant(Long productId, Long variantId, ProductVariantRequest request) { - validationService.validateVariantRequest(request); - - ProductVariantEntity variant = getVariant(productId, variantId); - updateVariantDetails(variant, request); - - ProductVariantEntity savedVariant = variantRepository.save(variant); - return variantMapper.apply(savedVariant); - } - - @Transactional - public void deleteVariant(Long productId, Long variantId) { - ProductVariantEntity variant = getVariant(productId, variantId); - validationService.validateVariantDeletion(variant); - variantRepository.delete(variant); - } - - @Transactional(readOnly = true) - public Page getProductVariants(Long productId, PageRequest pageRequest) { - // Verify product exists - getProduct(productId); - return variantRepository.findByProductId(productId, pageRequest) - .map(variantMapper); - } - - public ProductVariantEntityDto getVariantDto(Long productId, Long variantId) { - ProductVariantEntity variant = getVariant(productId, variantId); - ProductEntity product = variant.getProduct(); - - ProductVariantEntityDto dto = variantMapper.apply(variant); - - // Apply inheritance only when retrieving - dto.setStock(variant.getStock() != null ? variant.getStock() : product.getStock()); - dto.setImageUrl(variant.getImageUrl() != null ? variant.getImageUrl() : product.getImageUrl()); - dto.setUnitPrice(variant.getUnitPrice() != null ? variant.getUnitPrice() : product.getUnitPrice()); - - return dto; - } - - @Transactional(readOnly = true) - Set createVariantsFromRequest(List requests, ProductEntity product) { - return requests.stream() - .map(request -> createVariantFromRequest(request, product)) - .collect(Collectors.toSet()); - } - - private ProductEntity getProduct(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new ProductNotFoundException(productId)); - } - - private ProductVariantEntity getVariant(Long productId, Long variantId) { - ProductEntity product = getProduct(productId); - return product.getVariants().stream() - .filter(variant -> variant.getId().equals(variantId)) - .findFirst() - .orElseThrow(() -> new RuntimeException( - String.format("Variant %d not found for product %d", variantId, product.getId()) - )); - } - - private ProductVariantEntity createVariantFromRequest(ProductVariantRequest request, ProductEntity product) { - ProductVariantEntity variant = new ProductVariantEntity(); - updateVariantDetails(variant, request); - variant.setProduct(product); - return variant; - } - - private void updateVariantDetails(ProductVariantEntity variant, ProductVariantRequest request) { - variant.setSku(request.sku()); - variant.setStock(request.stock()); - variant.setImageUrl(request.imageUrl()); - variant.setUnitPrice(request.unitPrice()); - updateVariantOptions(variant, request.options()); - } - - private void updateVariantOptions(ProductVariantEntity variant, List options) { - variant.getOptions().clear(); - options.forEach(optionRequest -> { - VariantOptionEntity option = VariantOptionEntity.builder() - .name(optionRequest.name()) - .value(optionRequest.value()) - .productVariant(variant) - .build(); - variant.addOption(option); - }); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderLineViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderLineViewModel.java deleted file mode 100644 index 165fcf6..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderLineViewModel.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -import com.zenfulcode.commercify.commercify.dto.OrderLineDTO; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; - -public record OrderLineViewModel( - String name, - String description, - Integer quantity, - String imageUrl, - Double unitPrice, - ProductVariantViewModel variant -) { - public static OrderLineViewModel fromDTO(OrderLineDTO orderLineDTO) { - ProductDTO product = orderLineDTO.getProduct(); - return new OrderLineViewModel( - product.getName(), - product.getDescription(), - orderLineDTO.getQuantity(), - product.getImageUrl(), - orderLineDTO.getUnitPrice(), - orderLineDTO.getVariant() != null ? - ProductVariantViewModel.fromDTO(orderLineDTO.getVariant()) : - null - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderViewModel.java deleted file mode 100644 index 7b2831e..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/OrderViewModel.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; - -import java.time.Instant; - -public record OrderViewModel( - long id, - Long userId, - int orderLinesAmount, - double totalPrice, - String currency, - OrderStatus orderStatus, - Instant createdAt -) { - public static OrderViewModel fromDTO(OrderDTO orderDTO) { - return new OrderViewModel( - orderDTO.getId(), - orderDTO.getUserId(), - orderDTO.getOrderLinesAmount(), - orderDTO.getSubTotal(), - orderDTO.getCurrency(), - orderDTO.getOrderStatus(), - orderDTO.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/PriceViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/PriceViewModel.java deleted file mode 100644 index 93f2143..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/PriceViewModel.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -public record PriceViewModel(String currency, - Double amount) { - -} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductVariantViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductVariantViewModel.java deleted file mode 100644 index b9c9563..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductVariantViewModel.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.zenfulcode.commercify.commercify.dto.ProductVariantEntityDto; - -import java.util.List; -import java.util.stream.Collectors; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record ProductVariantViewModel( - Long id, - String sku, - List options -) { - public static ProductVariantViewModel fromDTO(ProductVariantEntityDto dto) { - return new ProductVariantViewModel( - dto.getId(), - dto.getSku(), - dto.getOptions().stream() - .map(VariantOptionViewModel::fromDTO) - .collect(Collectors.toList()) - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductViewModel.java deleted file mode 100644 index e046e3d..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/ProductViewModel.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; - -import java.util.List; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record ProductViewModel( - Long id, - String name, - String description, - Integer stock, - String imageUrl, - Boolean active, - PriceViewModel price, - List variants -) { - public static ProductViewModel fromDTO(ProductDTO productDTO) { - return new ProductViewModel( - productDTO.getId(), - productDTO.getName(), - productDTO.getDescription() != null ? productDTO.getDescription() : null, - productDTO.getStock(), - productDTO.getImageUrl() != null ? productDTO.getImageUrl() : null, - productDTO.getActive(), - new PriceViewModel( - productDTO.getCurrency(), - productDTO.getUnitPrice() - ), - productDTO.getVariants().stream() - .map(ProductVariantViewModel::fromDTO) - .toList() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/VariantOptionViewModel.java b/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/VariantOptionViewModel.java deleted file mode 100644 index fbac540..0000000 --- a/src/main/java/com/zenfulcode/commercify/commercify/viewmodel/VariantOptionViewModel.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.zenfulcode.commercify.commercify.viewmodel; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.zenfulcode.commercify.commercify.dto.VariantOptionEntityDto; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record VariantOptionViewModel( - String name, - String value -) { - public static VariantOptionViewModel fromDTO(VariantOptionEntityDto dto) { - return new VariantOptionViewModel( - dto.getName(), - dto.getValue() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/metrics/application/MetricsApplicationService.java b/src/main/java/com/zenfulcode/commercify/metrics/application/MetricsApplicationService.java new file mode 100644 index 0000000..fce69ba --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/metrics/application/MetricsApplicationService.java @@ -0,0 +1,91 @@ +package com.zenfulcode.commercify.metrics.application; + +import com.zenfulcode.commercify.api.system.dto.MetricsResponse; +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; +import com.zenfulcode.commercify.order.application.query.CalculateTotalRevenueQuery; +import com.zenfulcode.commercify.order.application.query.CountOrdersInPeriodQuery; +import com.zenfulcode.commercify.order.application.service.OrderApplicationService; +import com.zenfulcode.commercify.product.application.query.CountNewProductsInPeriodQuery; +import com.zenfulcode.commercify.product.application.service.ProductApplicationService; +import com.zenfulcode.commercify.user.application.query.CountActiveUsersInPeriodQuery; +import com.zenfulcode.commercify.user.application.service.UserApplicationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MetricsApplicationService { + private final OrderApplicationService orderApplicationService; + private final ProductApplicationService productApplicationService; + private final UserApplicationService userApplicationService; + + @Cacheable(value = "metricsCache", key = "{#metricsQuery.startDate, #metricsQuery.endDate, #metricsQuery.productCategory, #metricsQuery.region}") + public MetricsResponse getMetrics(MetricsQuery metricsQuery) { + LocalDate startDate = metricsQuery.startDate(); + LocalDate endDate = metricsQuery.endDate(); + + log.info("Calculating metrics from {} to {}", startDate, endDate); + + final CalculateTotalRevenueQuery calculateTotalRevenueQuery = CalculateTotalRevenueQuery.of(metricsQuery); + final CountOrdersInPeriodQuery countOrdersInPeriodQuery = CountOrdersInPeriodQuery.of(metricsQuery); + final CountNewProductsInPeriodQuery countNewProductsInPeriodQuery = CountNewProductsInPeriodQuery.of(metricsQuery); + final CountActiveUsersInPeriodQuery countActiveUsersInPeriodQuery = CountActiveUsersInPeriodQuery.of(metricsQuery); + + // Get metrics in parallel for better performance + BigDecimal totalRevenue = orderApplicationService.calculateTotalRevenue(calculateTotalRevenueQuery); + int totalOrders = orderApplicationService.countOrdersInPeriod(countOrdersInPeriodQuery); + int newProductsAdded = productApplicationService.countNewProductsInPeriod(countNewProductsInPeriodQuery); + int activeUsers = userApplicationService.countActiveUsersInPeriod(countActiveUsersInPeriodQuery); + + // Optional: calculate trends compared to previous period + BigDecimal revenueChangePercent = calculateRevenueChangePercent(startDate, endDate, metricsQuery); + + return MetricsResponse.builder() + .startDate(startDate) + .endDate(endDate) + .totalRevenue(totalRevenue) + .totalOrders(totalOrders) + .newProductsAdded(newProductsAdded) + .activeUsers(activeUsers) + .revenueChangePercent(revenueChangePercent) + .build(); + } + + private BigDecimal calculateRevenueChangePercent(LocalDate startDate, LocalDate endDate, MetricsQuery metricsQuery) { + // Calculate the same period length before the startDate + long periodDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate); + LocalDate previousPeriodStart = startDate.minusDays(periodDays); + LocalDate previousPeriodEnd = startDate.minusDays(1); + + final CalculateTotalRevenueQuery currentRevenueQuery = new CalculateTotalRevenueQuery( + metricsQuery.productCategory(), + metricsQuery.region(), + startDate, + endDate + ); + + final CalculateTotalRevenueQuery previousRevenueQuery = new CalculateTotalRevenueQuery( + metricsQuery.productCategory(), + metricsQuery.region(), + previousPeriodStart, + previousPeriodEnd + ); + + BigDecimal currentRevenue = orderApplicationService.calculateTotalRevenue(currentRevenueQuery); + BigDecimal previousRevenue = orderApplicationService.calculateTotalRevenue(previousRevenueQuery); + + if (previousRevenue.equals(BigDecimal.ZERO)) { + return currentRevenue.equals(BigDecimal.ZERO) ? BigDecimal.ZERO : new BigDecimal(100); + } + + return currentRevenue.subtract(previousRevenue) + .multiply(new BigDecimal(100)).divide(previousRevenue, 2, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/metrics/application/dto/MetricsQuery.java b/src/main/java/com/zenfulcode/commercify/metrics/application/dto/MetricsQuery.java new file mode 100644 index 0000000..51ea828 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/metrics/application/dto/MetricsQuery.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.metrics.application.dto; + +import com.zenfulcode.commercify.api.system.dto.MetricsRequest; + +import java.time.LocalDate; + +public record MetricsQuery(LocalDate startDate, LocalDate endDate, String productCategory, + String region) { + + public static MetricsQuery of(MetricsRequest metricsRequest) { + return new MetricsQuery( + metricsRequest.getEffectiveStartDate(), + metricsRequest.getEffectiveEndDate(), + metricsRequest.getProductCategory(), + metricsRequest.getRegion() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/command/CancelOrderCommand.java b/src/main/java/com/zenfulcode/commercify/order/application/command/CancelOrderCommand.java new file mode 100644 index 0000000..f4d97ca --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/command/CancelOrderCommand.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.order.application.command; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; + +public record CancelOrderCommand( + OrderId orderId +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/application/command/CreateOrderCommand.java b/src/main/java/com/zenfulcode/commercify/order/application/command/CreateOrderCommand.java new file mode 100644 index 0000000..a83639d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/command/CreateOrderCommand.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.order.application.command; + +import com.zenfulcode.commercify.order.domain.valueobject.Address; +import com.zenfulcode.commercify.order.domain.valueobject.CustomerDetails; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineDetails; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; + +import java.util.List; + +public record CreateOrderCommand( + UserId customerId, + String currency, + CustomerDetails customerDetails, + Address shippingAddress, + Address billingAddress, + List orderLines +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/command/GetOrderByIdCommand.java b/src/main/java/com/zenfulcode/commercify/order/application/command/GetOrderByIdCommand.java new file mode 100644 index 0000000..c28c2d5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/command/GetOrderByIdCommand.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.order.application.command; + +public record GetOrderByIdCommand(String orderId) { + public GetOrderByIdCommand { + if (orderId == null || orderId.isBlank()) { + throw new IllegalArgumentException("Order ID cannot be null or empty"); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/command/UpdateOrderStatusCommand.java b/src/main/java/com/zenfulcode/commercify/order/application/command/UpdateOrderStatusCommand.java new file mode 100644 index 0000000..b7c3888 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/command/UpdateOrderStatusCommand.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.order.application.command; + +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; + +public record UpdateOrderStatusCommand( + OrderId orderId, + OrderStatus newStatus +) {} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderDetailsDTO.java b/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderDetailsDTO.java new file mode 100644 index 0000000..212e367 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderDetailsDTO.java @@ -0,0 +1,41 @@ +package com.zenfulcode.commercify.order.application.dto; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.Address; +import com.zenfulcode.commercify.order.domain.valueobject.CustomerDetails; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +public record OrderDetailsDTO(OrderId id, + UserId userId, + OrderStatus status, + String currency, + Money totalAmount, + List orderLines, + CustomerDetails customerDetails, + Address shippingAddress, + Address billingAddress, + Instant createdAt) { + public static OrderDetailsDTO fromOrder(Order order) { + return new OrderDetailsDTO( + order.getId(), + order.getUser().getId(), + order.getStatus(), + order.getCurrency(), + order.getTotalAmount(), + order.getOrderLines().stream() + .map(OrderLineDTO::fromOrderLine) + .collect(Collectors.toList()), + order.getOrderShippingInfo().toCustomerDetails(), + order.getOrderShippingInfo().toShippingAddress(), + order.getOrderShippingInfo().toBillingAddress(), + order.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderLineDTO.java b/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderLineDTO.java new file mode 100644 index 0000000..0d958be --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/dto/OrderLineDTO.java @@ -0,0 +1,31 @@ +package com.zenfulcode.commercify.order.application.dto; + +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.Builder; + +@Builder +public record OrderLineDTO(OrderLineId id, + ProductId productId, + VariantId variantId, + int quantity, + Money unitPrice, + Money total, + String variantSku) { + public static OrderLineDTO fromOrderLine(OrderLine orderLine) { + return OrderLineDTO.builder() + .id(orderLine.getId()) + .productId(orderLine.getProduct().getId()) + .variantId(orderLine.getProductVariant() != null ? + orderLine.getProductVariant().getId() : null) + .quantity(orderLine.getQuantity()) + .unitPrice(orderLine.getUnitPrice()) + .total(orderLine.getTotal()) + .variantSku(orderLine.getProductVariant() != null ? + orderLine.getProductVariant().getSku() : null) + .build(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/query/CalculateTotalRevenueQuery.java b/src/main/java/com/zenfulcode/commercify/order/application/query/CalculateTotalRevenueQuery.java new file mode 100644 index 0000000..c12cd53 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/query/CalculateTotalRevenueQuery.java @@ -0,0 +1,21 @@ +package com.zenfulcode.commercify.order.application.query; + +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; + +import java.time.LocalDate; + +public record CalculateTotalRevenueQuery( + String productCategory, + String region, + LocalDate startDate, + LocalDate endDate +) { + public static CalculateTotalRevenueQuery of(MetricsQuery metricsQuery) { + return new CalculateTotalRevenueQuery( + metricsQuery.productCategory(), + metricsQuery.region(), + metricsQuery.startDate(), + metricsQuery.endDate() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/query/CountOrdersInPeriodQuery.java b/src/main/java/com/zenfulcode/commercify/order/application/query/CountOrdersInPeriodQuery.java new file mode 100644 index 0000000..1fa3184 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/query/CountOrdersInPeriodQuery.java @@ -0,0 +1,21 @@ +package com.zenfulcode.commercify.order.application.query; + +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; + +import java.time.LocalDate; + +public record CountOrdersInPeriodQuery( + String productCategory, + String region, + LocalDate startDate, + LocalDate endDate +) { + public static CountOrdersInPeriodQuery of(MetricsQuery metricsQuery) { + return new CountOrdersInPeriodQuery( + metricsQuery.productCategory(), + metricsQuery.region(), + metricsQuery.startDate(), + metricsQuery.endDate() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/query/FindAllOrdersQuery.java b/src/main/java/com/zenfulcode/commercify/order/application/query/FindAllOrdersQuery.java new file mode 100644 index 0000000..1ea8181 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/query/FindAllOrdersQuery.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.order.application.query; + +import org.springframework.data.domain.PageRequest; + +public record FindAllOrdersQuery( + PageRequest pageRequest +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/query/FindOrdersByUserIdQuery.java b/src/main/java/com/zenfulcode/commercify/order/application/query/FindOrdersByUserIdQuery.java new file mode 100644 index 0000000..b66925c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/query/FindOrdersByUserIdQuery.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.order.application.query; + +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.PageRequest; + +public record FindOrdersByUserIdQuery( + UserId userId, + PageRequest pageRequest +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/order/application/service/OrderApplicationService.java b/src/main/java/com/zenfulcode/commercify/order/application/service/OrderApplicationService.java new file mode 100644 index 0000000..15852fb --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/application/service/OrderApplicationService.java @@ -0,0 +1,131 @@ +package com.zenfulcode.commercify.order.application.service; + +import com.zenfulcode.commercify.order.application.command.CancelOrderCommand; +import com.zenfulcode.commercify.order.application.command.CreateOrderCommand; +import com.zenfulcode.commercify.order.application.command.GetOrderByIdCommand; +import com.zenfulcode.commercify.order.application.command.UpdateOrderStatusCommand; +import com.zenfulcode.commercify.order.application.dto.OrderDetailsDTO; +import com.zenfulcode.commercify.order.application.query.CalculateTotalRevenueQuery; +import com.zenfulcode.commercify.order.application.query.CountOrdersInPeriodQuery; +import com.zenfulcode.commercify.order.application.query.FindAllOrdersQuery; +import com.zenfulcode.commercify.order.application.query.FindOrdersByUserIdQuery; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.service.OrderDomainService; +import com.zenfulcode.commercify.order.domain.valueobject.OrderDetails; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineDetails; +import com.zenfulcode.commercify.product.application.service.ProductApplicationService; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderApplicationService { + private final OrderDomainService orderDomainService; + private final DomainEventPublisher eventPublisher; + private final ProductApplicationService productApplicationService; + + @Transactional + public OrderId createOrder(CreateOrderCommand command) { + // Get products and variants + List productIds = command.orderLines() + .stream() + .map(OrderLineDetails::productId) + .collect(Collectors.toList()); + + List products = productApplicationService.findAllProducts(productIds); + + List variantIds = command.orderLines() + .stream() + .map(OrderLineDetails::variantId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + List variants = productApplicationService.findVariantsByIds(variantIds); + + // Create order through domain service + Order order = orderDomainService.createOrder( + OrderDetails.builder() + .customerId(command.customerId()) + .currency(command.currency()) + .customerDetails(command.customerDetails()) + .shippingAddress(command.shippingAddress()) + .billingAddress(command.billingAddress()) + .orderLines(command.orderLines()) + .build(), + products, + variants + ); + + // publish events + eventPublisher.publish(order.getDomainEvents()); + + return order.getId(); + } + + @Transactional + public void updateOrderStatus(UpdateOrderStatusCommand command) { + Order order = orderDomainService.getOrderById(command.orderId()); + orderDomainService.updateOrderStatus(order, command.newStatus()); + + // Save and publish events + eventPublisher.publish(order.getDomainEvents()); + } + + @Transactional + public void cancelOrder(CancelOrderCommand command) { + Order order = orderDomainService.getOrderById(command.orderId()); + orderDomainService.updateOrderStatus(order, OrderStatus.CANCELLED); + eventPublisher.publish(order.getDomainEvents()); + } + + @Transactional(readOnly = true) + public Page findOrdersByUserId(FindOrdersByUserIdQuery query) { + return orderDomainService.findOrdersByUserId(query); + } + + @Transactional(readOnly = true) + public Page findAllOrders(FindAllOrdersQuery query) { + return orderDomainService.findAllOrders(query); + } + + @Transactional(readOnly = true) + public OrderDetailsDTO getOrderDetailsById(GetOrderByIdCommand command) { + OrderId orderId = OrderId.of(command.orderId()); + Order order = getOrderById(orderId); + return OrderDetailsDTO.fromOrder(order); + } + + @Transactional(readOnly = true) + public Order getOrderById(OrderId orderId) { + return orderDomainService.getOrderById(orderId); + } + + @Transactional(readOnly = true) + public boolean isOrderOwnedByUser(OrderId orderId, UserId userId) { + return orderDomainService.isOrderOwnedByUser(orderId, userId); + } + + @Transactional(readOnly = true) + public BigDecimal calculateTotalRevenue(CalculateTotalRevenueQuery query) { + return orderDomainService.calculateTotalRevenue(query.startDate(), query.endDate()); + } + + public int countOrdersInPeriod(CountOrdersInPeriodQuery query) { + return orderDomainService.countOrdersInPeriod(query.startDate(), query.endDate()); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderCreatedEvent.java b/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderCreatedEvent.java new file mode 100644 index 0000000..c2688c6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderCreatedEvent.java @@ -0,0 +1,31 @@ +package com.zenfulcode.commercify.order.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +import java.time.Instant; + +@Getter +public class OrderCreatedEvent extends DomainEvent { + @AggregateId + private final OrderId orderId; + private final UserId userId; + private final String currency; + private final Instant createdAt; + + public OrderCreatedEvent(Object source, OrderId orderId, UserId userId, String currency) { + super(source); + this.orderId = orderId; + this.userId = userId; + this.currency = currency; + this.createdAt = Instant.now(); + } + + @Override + public String getEventType() { + return "ORDER_CREATED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderStatusChangedEvent.java b/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderStatusChangedEvent.java new file mode 100644 index 0000000..22cb98c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/event/OrderStatusChangedEvent.java @@ -0,0 +1,52 @@ +package com.zenfulcode.commercify.order.domain.event; + +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +@Getter +public class OrderStatusChangedEvent extends DomainEvent { + @AggregateId + private final OrderId orderId; + private final OrderStatus oldStatus; + private final OrderStatus newStatus; + private final Instant changedAt; + + public OrderStatusChangedEvent( + Object source, + OrderId orderId, + OrderStatus oldStatus, + OrderStatus newStatus + ) { + super(source); + this.orderId = orderId; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedAt = Instant.now(); + } + + @Override + public String getEventType() { + return "ORDER_STATUS_CHANGED"; + } + + public boolean isPaidTransition() { + return newStatus == OrderStatus.PAID; + } + + public boolean isCompletedTransition() { + return newStatus == OrderStatus.COMPLETED; + } + + public boolean isCancellationTransition() { + return newStatus == OrderStatus.CANCELLED; + } + + public boolean isShippingTransition() { + return newStatus == OrderStatus.SHIPPED; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/exception/InvalidOrderStateTransitionException.java b/src/main/java/com/zenfulcode/commercify/order/domain/exception/InvalidOrderStateTransitionException.java new file mode 100644 index 0000000..7d97adb --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/exception/InvalidOrderStateTransitionException.java @@ -0,0 +1,28 @@ +package com.zenfulcode.commercify.order.domain.exception; + +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import lombok.Getter; + +@Getter +public class InvalidOrderStateTransitionException extends DomainException { + private final OrderId orderId; + private final OrderStatus currentStatus; + private final OrderStatus targetStatus; + + public InvalidOrderStateTransitionException( + OrderId orderId, + OrderStatus currentStatus, + OrderStatus targetStatus, + String message + ) { + super(String.format("Invalid order status transition from %s to %s for order %s: %s", + currentStatus, targetStatus, orderId, message)); + + this.orderId = orderId; + this.currentStatus = currentStatus; + this.targetStatus = targetStatus; + } + +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderNotFoundException.java b/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderNotFoundException.java new file mode 100644 index 0000000..f990f9c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderNotFoundException.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.order.domain.exception; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class OrderNotFoundException extends DomainException { + public OrderNotFoundException(OrderId orderId) { + super("Order not found with ID: " + orderId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderValidationException.java b/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderValidationException.java new file mode 100644 index 0000000..4651cec --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/exception/OrderValidationException.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.order.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +public class OrderValidationException extends DomainValidationException { + public OrderValidationException(String message) { + super(message, List.of(message)); + } + + public OrderValidationException(List violations) { + super("Order validation failed: " + String.join(", ", violations), violations); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderCreationException.java b/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderCreationException.java new file mode 100644 index 0000000..844c6d7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderCreationException.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.order.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainForbiddenException; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; + +public class UnauthorizedOrderCreationException extends DomainForbiddenException { + public UnauthorizedOrderCreationException(UserId userId) { + super("User is not authorized to create order for user ID: " + userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderFetchingException.java b/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderFetchingException.java new file mode 100644 index 0000000..a75b6b0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/exception/UnauthorizedOrderFetchingException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.order.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainForbiddenException; + +public class UnauthorizedOrderFetchingException extends DomainForbiddenException { + public UnauthorizedOrderFetchingException(String userId) { + super("User is not authorized to fetch order for user ID: " + userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/model/Order.java b/src/main/java/com/zenfulcode/commercify/order/domain/model/Order.java new file mode 100644 index 0000000..d6ff992 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/model/Order.java @@ -0,0 +1,180 @@ +package com.zenfulcode.commercify.order.domain.model; + +import com.zenfulcode.commercify.order.domain.event.OrderCreatedEvent; +import com.zenfulcode.commercify.order.domain.event.OrderStatusChangedEvent; +import com.zenfulcode.commercify.order.domain.exception.OrderValidationException; +import com.zenfulcode.commercify.order.domain.service.OrderStateFlow; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.shared.domain.model.AggregateRoot; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "orders") +public class Order extends AggregateRoot { + @EmbeddedId + private OrderId id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Column(name = "currency") + private String currency; + + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private Set orderLines = new LinkedHashSet<>(); + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "subtotal")), + @AttributeOverride(name = "currency", column = @Column(name = "currency", insertable = false, updatable = false)) + }) + private Money subtotal; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "shipping_cost")), + @AttributeOverride(name = "currency", column = @Column(name = "currency", insertable = false, updatable = false)) + }) + private Money shippingCost; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "tax")), + @AttributeOverride(name = "currency", column = @Column(name = "currency", insertable = false, updatable = false)) + }) + private Money tax; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "total_amount")), + @AttributeOverride(name = "currency", column = @Column(name = "currency", insertable = false, updatable = false)) + }) + private Money totalAmount; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "order_shipping_info_id") + private OrderShippingInfo orderShippingInfo; + + @CreationTimestamp + @Column(name = "created_at") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + // Factory method + public static Order create( + UserId userId, + String currency, + OrderShippingInfo shippingInfo + ) { + Order order = new Order(); + order.id = OrderId.generate(); + order.currency = currency; + order.status = OrderStatus.PENDING; + order.orderShippingInfo = shippingInfo; + order.totalAmount = Money.zero("USD"); + + // Register domain event + order.registerEvent(new OrderCreatedEvent( + order, + order.getId(), + userId, + order.getCurrency() + )); + + return order; + } + + public void addOrderLine(OrderLine orderLine) { + orderLines.add(orderLine); + orderLine.setOrder(this); + recalculateTotal(); + } + + public void removeOrderLine(OrderLine orderLine) { + orderLines.remove(orderLine); + orderLine.setOrder(null); + recalculateTotal(); + } + + public void updateStatus(OrderStatus newStatus) { + OrderStatus oldStatus = this.status; + this.status = newStatus; + + registerEvent(new OrderStatusChangedEvent( + this, + this.id, + oldStatus, + newStatus + )); + } + + public void updateTotal() { + this.totalAmount = subtotal + .add(shippingCost != null ? shippingCost : Money.zero(currency)) + .add(tax != null ? tax : Money.zero(currency)); + } + + public void setSubtotal(Money subtotal) { + validateSameCurrency(subtotal); + this.subtotal = subtotal; + } + + public void setShippingCost(Money shippingCost) { + validateSameCurrency(shippingCost); + this.shippingCost = shippingCost; + } + + public void setTax(Money tax) { + validateSameCurrency(tax); + this.tax = tax; + } + + private void validateSameCurrency(Money money) { + if (!currency.equals(money.getCurrency())) { + throw new OrderValidationException( + String.format("Currency mismatch: Expected %s but got %s", + currency, money.getCurrency()) + ); + } + } + + private void recalculateTotal() { + this.totalAmount = orderLines.stream() + .map(OrderLine::getTotal) + .reduce(Money.zero(currency), Money::add); + } + + public boolean isInTerminalState(OrderStateFlow stateFlow) { + return stateFlow.isTerminalState(status); + } + + public Set getValidTransitions(OrderStateFlow stateFlow) { + return stateFlow.getValidTransitions(status); + } + + public boolean isCompleted() { + return status == OrderStatus.COMPLETED; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderLine.java b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderLine.java new file mode 100644 index 0000000..f60ae93 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderLine.java @@ -0,0 +1,58 @@ +package com.zenfulcode.commercify.order.domain.model; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineId; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.shared.domain.model.Money; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "order_lines") +public class OrderLine { + @EmbeddedId + private OrderLineId id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_variant_id") + private ProductVariant productVariant; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "unit_price")), + @AttributeOverride(name = "currency", column = @Column(name = "currency")) + }) + private Money unitPrice; + + public static OrderLine create( + Product product, + ProductVariant variant, + Integer quantity + ) { + OrderLine line = new OrderLine(); + line.id = OrderLineId.generate(); + line.product = product; + line.productVariant = variant; + line.quantity = quantity; + line.unitPrice = product.getEffectivePrice(variant); + return line; + } + + public Money getTotal() { + return unitPrice.multiply(quantity); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderShippingInfo.java b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderShippingInfo.java new file mode 100644 index 0000000..6006006 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderShippingInfo.java @@ -0,0 +1,134 @@ +package com.zenfulcode.commercify.order.domain.model; + +import com.zenfulcode.commercify.order.domain.valueobject.Address; +import com.zenfulcode.commercify.order.domain.valueobject.CustomerDetails; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "order_shipping_info") +public class OrderShippingInfo { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "customer_first_name") + private String customerFirstName; + + @Column(name = "customer_last_name") + private String customerLastName; + + @Column(name = "customer_email") + private String customerEmail; + + @Column(name = "customer_phone") + private String customerPhone; + + // Shipping address + @Column(name = "shipping_street", nullable = false) + private String shippingStreet; + + @Column(name = "shipping_city", nullable = false) + private String shippingCity; + + @Column(name = "shipping_state") + private String shippingState; + + @Column(name = "shipping_zip", nullable = false) + private String shippingZip; + + @Column(name = "shipping_country", nullable = false) + private String shippingCountry; + + // Billing address + @Column(name = "billing_street") + private String billingStreet; + + @Column(name = "billing_city") + private String billingCity; + + @Column(name = "billing_state") + private String billingState; + + @Column(name = "billing_zip") + private String billingZip; + + @Column(name = "billing_country") + private String billingCountry; + + public static OrderShippingInfo create( + CustomerDetails customerDetails, + Address shippingAddress, + Address billingAddress + ) { + OrderShippingInfo info = new OrderShippingInfo(); + + // Set customer details + info.customerFirstName = customerDetails.firstName(); + info.customerLastName = customerDetails.lastName(); + info.customerEmail = customerDetails.email(); + info.customerPhone = customerDetails.phone(); + + // Set shipping address + info.shippingStreet = shippingAddress.street(); + info.shippingCity = shippingAddress.city(); + info.shippingState = shippingAddress.state(); + info.shippingZip = shippingAddress.zipCode(); + info.shippingCountry = shippingAddress.country(); + + // Set billing address if provided + if (billingAddress != null) { + info.billingStreet = billingAddress.street(); + info.billingCity = billingAddress.city(); + info.billingState = billingAddress.state(); + info.billingZip = billingAddress.zipCode(); + info.billingCountry = billingAddress.country(); + } + + return info; + } + + public String getCustomerName() { + return customerFirstName + " " + customerLastName; + } + + public CustomerDetails toCustomerDetails() { + return new CustomerDetails( + customerFirstName, + customerLastName, + customerEmail, + customerPhone + ); + } + + public Address toShippingAddress() { + return new Address( + shippingStreet, + shippingCity, + shippingState, + shippingZip, + shippingCountry + ); + } + + public Address toBillingAddress() { + if (!hasBillingAddress()) { + return toShippingAddress(); + } + return new Address( + billingStreet, + billingCity, + billingState, + billingZip, + billingCountry + ); + } + + public boolean hasBillingAddress() { + return billingStreet != null && !billingStreet.isBlank(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderStatus.java b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderStatus.java new file mode 100644 index 0000000..38ba20c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/model/OrderStatus.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.order.domain.model; + +public enum OrderStatus { + PENDING, // Order has been created + ABANDONED, // Order has been abandoned + PAID, // Order has been paid + SHIPPED, // Order has been shipped + COMPLETED, // Order has been delivered + CANCELLED, // Order has been cancelled + REFUNDED, // Order has been refunded/returned + FAILED // Order has failed +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderLineRepository.java b/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderLineRepository.java new file mode 100644 index 0000000..680d7ef --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderLineRepository.java @@ -0,0 +1,36 @@ +package com.zenfulcode.commercify.order.domain.repository; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface OrderLineRepository { + OrderLine save(OrderLine orderLine); + + List findByOrderId(OrderId orderId); + + Set findActiveOrdersForProduct( + ProductId productId, + Collection statuses + ); + + Set findActiveOrdersForVariant( + VariantId variantId, + Collection statuses + ); + + boolean hasActiveOrders( + ProductId productId + ); + + boolean hasActiveOrdersForVariant( + VariantId variantId + ); +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderRepository.java b/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderRepository.java new file mode 100644 index 0000000..4ed763e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/repository/OrderRepository.java @@ -0,0 +1,29 @@ +package com.zenfulcode.commercify.order.domain.repository; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + + Optional findById(OrderId id); + + Page findByUserId(UserId userId, PageRequest pageRequest); + + Page findAll(PageRequest pageRequest); + + boolean existsByIdAndUserId(OrderId id, UserId userId); + + boolean existsByUserId(UserId userId); + + Optional calculateTotalRevenue(Instant startDate, Instant endDate); + + int countOrders(Instant startDate, Instant endDate); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/service/DefaultOrderPricingStrategy.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/DefaultOrderPricingStrategy.java new file mode 100644 index 0000000..13688d7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/DefaultOrderPricingStrategy.java @@ -0,0 +1,40 @@ +package com.zenfulcode.commercify.order.domain.service; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +public class DefaultOrderPricingStrategy implements OrderPricingStrategy { + // TODO: Currency conversion + private static final BigDecimal TAX_RATE = new BigDecimal("0.20"); // 20% tax + private static final Money FREE_SHIPPING_THRESHOLD = Money.of(new BigDecimal("100"), "USD"); + private static final Money STANDARD_SHIPPING = Money.of(new BigDecimal("10"), "USD"); + + @Override + public Money calculateSubtotal(Order order) { + return order.getOrderLines().stream() + .map(OrderLine::getTotal) + .reduce(Money.zero(order.getCurrency()), Money::add); + } + + @Override + public Money calculateShippingCost(Order order) { + Money subtotal = calculateSubtotal(order); + if (subtotal.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD)) { + return Money.zero(order.getCurrency()); + } + return STANDARD_SHIPPING; + } + + @Override + public Money calculateTax(Order order) { + Money subtotal = calculateSubtotal(order); + return subtotal.multiply(TAX_RATE); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderDomainService.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderDomainService.java new file mode 100644 index 0000000..f7c6e72 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderDomainService.java @@ -0,0 +1,158 @@ +package com.zenfulcode.commercify.order.domain.service; + +import com.zenfulcode.commercify.order.application.query.FindAllOrdersQuery; +import com.zenfulcode.commercify.order.application.query.FindOrdersByUserIdQuery; +import com.zenfulcode.commercify.order.domain.exception.OrderNotFoundException; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.model.OrderShippingInfo; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.repository.OrderRepository; +import com.zenfulcode.commercify.order.domain.valueobject.OrderDetails; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineDetails; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.service.UserDomainService; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderDomainService { + private final OrderPricingStrategy pricingStrategy; + private final OrderValidationService validationService; + + private final OrderRepository orderRepository; + + private final UserDomainService userDomainService; + + public Order createOrder(OrderDetails orderDetails, List products, List variants) { + // Create order with shipping info + OrderShippingInfo shippingInfo = OrderShippingInfo.create( + orderDetails.customerDetails(), + orderDetails.shippingAddress(), + orderDetails.billingAddress() + ); + + User customer = userDomainService.getUserById(orderDetails.customerId()); + + Order order = Order.create( + customer.getId(), + orderDetails.currency(), + shippingInfo + ); + + order.setUser(customer); + + // Map products and variants for lookup + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + Map variantMap = variants.stream() + .collect(Collectors.toMap(ProductVariant::getId, Function.identity())); + + // Add order lines with validation + for (OrderLineDetails lineDetails : orderDetails.orderLines()) { + Product product = productMap.get(lineDetails.productId()); + // Using validationService for stock validation + validationService.validateStock(product, lineDetails.quantity()); + + ProductVariant variant = null; + if (lineDetails.variantId() != null) { + variant = variantMap.get(lineDetails.variantId()); + validationService.validateVariant(variant, product, lineDetails); + } + + OrderLine line = OrderLine.create( + product, + variant, + lineDetails.quantity() + ); + + line.setProduct(product); + + order.addOrderLine(line); + } + + // Apply pricing + applyPricing(order); + + // Using validationService for order validation + validationService.validateCreateOrder(order); + + orderRepository.save(order); + + return order; + } + + private void applyPricing(Order order) { + Money subtotal = pricingStrategy.calculateSubtotal(order); + order.setSubtotal(subtotal); + +// Money shippingCost = pricingStrategy.calculateShippingCost(order); +// order.setShippingCost(shippingCost); + + Money tax = pricingStrategy.calculateTax(order); + order.setTax(tax); + + order.updateTotal(); + orderRepository.save(order); + } + + public void updateOrderStatus(Order order, OrderStatus newStatus) { + // Using validationService for status transition validation + validationService.validateStatusTransition(order, newStatus); + + order.updateStatus(newStatus); + orderRepository.save(order); + } + + public Order getOrderById(OrderId orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new OrderNotFoundException(orderId)); + } + + public Page findOrdersByUserId(FindOrdersByUserIdQuery query) { + return orderRepository.findByUserId(query.userId(), query.pageRequest()); + } + + public Page findAllOrders(FindAllOrdersQuery query) { + return orderRepository.findAll(query.pageRequest()); + } + + public boolean isOrderOwnedByUser(OrderId orderId, UserId userId) { + return orderRepository.existsByIdAndUserId(orderId, userId); + } + + public BigDecimal calculateTotalRevenue(LocalDate startDate, LocalDate endDate) { + Instant start = startDate.atStartOfDay().toInstant(ZoneOffset.from(ZoneOffset.UTC)); + Instant end = endDate.atTime(23, 59).toInstant(ZoneOffset.UTC); + + // Implement logic to calculate total revenue based on the provided parameters + // This is a placeholder implementation and should be replaced with actual logic + return orderRepository.calculateTotalRevenue(start, end) + .orElse(BigDecimal.ZERO); + } + + public int countOrdersInPeriod(LocalDate startDate, LocalDate endDate) { + Instant start = startDate.atStartOfDay().toInstant(ZoneOffset.from(ZoneOffset.UTC)); + Instant end = endDate.atTime(23, 59).toInstant(ZoneOffset.UTC); + + return orderRepository.countOrders(start, end); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderNotificationService.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderNotificationService.java new file mode 100644 index 0000000..be03a41 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderNotificationService.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.order.domain.service; + +import com.zenfulcode.commercify.order.domain.model.Order; + +public interface OrderNotificationService { + void sendOrderConfirmation(Order order); + + void sendOrderStatusUpdate(Order order); + + void sendShippingConfirmation(Order order); + + void notifyAdminNewOrder(Order order); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderPricingStrategy.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderPricingStrategy.java new file mode 100644 index 0000000..ed6c93c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderPricingStrategy.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.order.domain.service; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.shared.domain.model.Money; + +public interface OrderPricingStrategy { + Money calculateSubtotal(Order order); + + Money calculateShippingCost(Order order); + + Money calculateTax(Order order); +} + diff --git a/src/main/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlow.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderStateFlow.java similarity index 73% rename from src/main/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlow.java rename to src/main/java/com/zenfulcode/commercify/order/domain/service/OrderStateFlow.java index 2d8bbaf..8765a0b 100644 --- a/src/main/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlow.java +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderStateFlow.java @@ -1,6 +1,6 @@ -package com.zenfulcode.commercify.commercify.flow; +package com.zenfulcode.commercify.order.domain.service; -import com.zenfulcode.commercify.commercify.OrderStatus; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; import org.springframework.stereotype.Component; import java.util.EnumMap; @@ -13,29 +13,31 @@ public class OrderStateFlow { public OrderStateFlow() { validTransitions = new EnumMap<>(OrderStatus.class); - // Initial state -> Confirmed or Cancelled validTransitions.put(OrderStatus.PENDING, Set.of( OrderStatus.PAID, - OrderStatus.CANCELLED + OrderStatus.ABANDONED + )); + + validTransitions.put(OrderStatus.ABANDONED, Set.of( + OrderStatus.PENDING )); - // Payment received -> Processing or Cancelled validTransitions.put(OrderStatus.PAID, Set.of( OrderStatus.SHIPPED, - OrderStatus.CANCELLED, - OrderStatus.COMPLETED + OrderStatus.COMPLETED, + OrderStatus.CANCELLED )); - // Shipped -> Completed or Returned validTransitions.put(OrderStatus.SHIPPED, Set.of( - OrderStatus.COMPLETED, - OrderStatus.RETURNED + OrderStatus.COMPLETED + )); + + validTransitions.put(OrderStatus.COMPLETED, Set.of( + OrderStatus.REFUNDED )); // Terminal states - validTransitions.put(OrderStatus.COMPLETED, Set.of()); validTransitions.put(OrderStatus.CANCELLED, Set.of()); - validTransitions.put(OrderStatus.RETURNED, Set.of()); validTransitions.put(OrderStatus.REFUNDED, Set.of()); } @@ -50,4 +52,4 @@ public Set getValidTransitions(OrderStatus currentState) { public boolean isTerminalState(OrderStatus state) { return validTransitions.get(state).isEmpty(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderValidationService.java b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderValidationService.java new file mode 100644 index 0000000..53ed349 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/service/OrderValidationService.java @@ -0,0 +1,111 @@ +package com.zenfulcode.commercify.order.domain.service; + +import com.zenfulcode.commercify.order.domain.exception.InvalidOrderStateTransitionException; +import com.zenfulcode.commercify.order.domain.exception.OrderValidationException; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderLineDetails; +import com.zenfulcode.commercify.product.domain.exception.InsufficientStockException; +import com.zenfulcode.commercify.product.domain.exception.VariantNotFoundException; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class OrderValidationService { + private final OrderStateFlow stateFlow; + + public void validateCreateOrder(Order order) { + List violations = new ArrayList<>(); + + if (order.getOrderLines().isEmpty()) { + violations.add("Order must contain at least one item"); + } + + if (order.getCurrency() == null || order.getCurrency().isBlank()) { + violations.add("Currency is required"); + } + + if (order.getOrderShippingInfo() == null) { + violations.add("Shipping information is required"); + } + + validateOrderLines(order.getOrderLines(), violations); + + if (!violations.isEmpty()) { + throw new OrderValidationException(violations); + } + } + + private void validateOrderLines(Set orderLines, List violations) { + for (OrderLine line : orderLines) { + if (line.getProduct() == null) { + violations.add("Product is required for order line"); + } + + if (line.getProduct().hasVariants() && line.getProductVariant() == null) { + violations.add("Variant is required for product with variants"); + } + + if (line.getQuantity() <= 0) { + violations.add("Quantity must be greater than 0 for product: " + line.getProduct().getId()); + } + if (!line.getUnitPrice().isPositive()) { + violations.add("Unit price must be greater than 0 for product: " + line.getProduct().getId()); + } + } + } + + public void validateStatusTransition(Order order, OrderStatus newStatus) { + if (!stateFlow.canTransition(order.getStatus(), newStatus)) { + throw new InvalidOrderStateTransitionException( + order.getId(), + order.getStatus(), + newStatus, + "Invalid status transition" + ); + } + } + + public void validateStock(Product product, int requestedQuantity) { + if (!product.hasEnoughStock(requestedQuantity)) { + throw new InsufficientStockException( + product.getId(), + requestedQuantity, + product.getStock() + ); + } + } + + public void validateVariant(ProductVariant variant, Product product, OrderLineDetails lineDetails) { + List violations = new ArrayList<>(); + + if (variant == null) { + throw new VariantNotFoundException(lineDetails.variantId()); + } + + if (!variant.belongsTo(product)) { + violations.add(String.format("Variant %s does not belong to product %s", + variant.getId(), product.getId())); + } + + if (!variant.hasEnoughStock(lineDetails.quantity())) { + throw new InsufficientStockException( + variant.getId(), + lineDetails.quantity(), + variant.getStock() + ); + } + + if (!violations.isEmpty()) { + throw new OrderValidationException(violations); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/Address.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/Address.java new file mode 100644 index 0000000..6141c73 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/Address.java @@ -0,0 +1,24 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +public record Address( + String street, + String city, + String state, + String zipCode, + String country +) { + public Address { + if (street == null || street.isBlank()) { + throw new IllegalArgumentException("Street is required"); + } + if (city == null || city.isBlank()) { + throw new IllegalArgumentException("City is required"); + } + if (zipCode == null || zipCode.isBlank()) { + throw new IllegalArgumentException("Zip code is required"); + } + if (country == null || country.isBlank()) { + throw new IllegalArgumentException("Country is required"); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/CustomerDetails.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/CustomerDetails.java new file mode 100644 index 0000000..91f17ac --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/CustomerDetails.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +public record CustomerDetails( + String firstName, + String lastName, + String email, + String phone +) { + public CustomerDetails { + if (firstName == null || firstName.isBlank()) { + throw new IllegalArgumentException("First name is required"); + } + if (lastName == null || lastName.isBlank()) { + throw new IllegalArgumentException("Last name is required"); + } + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email is required"); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderDetails.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderDetails.java new file mode 100644 index 0000000..a5f9700 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderDetails.java @@ -0,0 +1,53 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +import com.zenfulcode.commercify.order.domain.exception.OrderValidationException; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Builder; + +import java.util.ArrayList; +import java.util.List; + +@Builder +public record OrderDetails( + UserId customerId, + String currency, + CustomerDetails customerDetails, + Address shippingAddress, + Address billingAddress, + List orderLines +) { + public OrderDetails { + validate(customerId, currency, customerDetails, shippingAddress, orderLines); + } + + private void validate( + UserId customerId, + String currency, + CustomerDetails customerDetails, + Address shippingAddress, + List orderLines + ) { + List violations = new ArrayList<>(); + + if (customerId == null) { + violations.add("Customer ID is required"); + } + + if (currency == null || currency.isBlank()) { + violations.add("Currency is required"); + } + if (customerDetails == null) { + violations.add("Customer details are required"); + } + if (shippingAddress == null) { + violations.add("Shipping address is required"); + } + if (orderLines == null || orderLines.isEmpty()) { + violations.add("Order must contain at least one item"); + } + + if (!violations.isEmpty()) { + throw new OrderValidationException(violations); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderId.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderId.java new file mode 100644 index 0000000..3c1f65b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderId.java @@ -0,0 +1,46 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderId { + private String id; + + private OrderId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static OrderId generate() { + return new OrderId(UUID.randomUUID().toString()); + } + + public static OrderId of(String id) { + return new OrderId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrderId that = (OrderId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineDetails.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineDetails.java new file mode 100644 index 0000000..e62e0aa --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineDetails.java @@ -0,0 +1,38 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +import com.zenfulcode.commercify.order.domain.exception.OrderValidationException; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; + +import java.util.ArrayList; +import java.util.List; + +public record OrderLineDetails( + ProductId productId, + VariantId variantId, + int quantity +) { + public OrderLineDetails { + validate(productId, quantity); + } + + private void validate(ProductId productId, int quantity) { + List violations = new ArrayList<>(); + + if (productId == null) { + violations.add("Product ID is required"); + } + + if (quantity <= 0) { + violations.add("Quantity must be greater than zero"); + } + + if (!violations.isEmpty()) { + throw new OrderValidationException(violations); + } + } + + public boolean hasVariant() { + return variantId != null; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineId.java b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineId.java new file mode 100644 index 0000000..3e5c718 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/domain/valueobject/OrderLineId.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.order.domain.valueobject; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderLineId { + @Column(name = "id") + private String id; + + private OrderLineId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static OrderLineId generate() { + return new OrderLineId(UUID.randomUUID().toString()); + } + + public static OrderLineId of(String id) { + return new OrderLineId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OrderLineId that = (OrderLineId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/messaging/events/OrderEmailHandler.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/messaging/events/OrderEmailHandler.java new file mode 100644 index 0000000..5cfd900 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/messaging/events/OrderEmailHandler.java @@ -0,0 +1,45 @@ +package com.zenfulcode.commercify.order.infrastructure.messaging.events; + +import com.zenfulcode.commercify.order.application.service.OrderApplicationService; +import com.zenfulcode.commercify.order.domain.event.OrderStatusChangedEvent; +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.infrastructure.notification.OrderEmailNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEmailHandler { + private final OrderApplicationService orderService; + private final OrderEmailNotificationService notificationService; + + @Async + @EventListener + @Transactional(readOnly = true) + public void handleOrderStatusChanged(OrderStatusChangedEvent event) { + log.info("Sending email confirmation notification for order: {}", event.getOrderId()); + try { + Order order = orderService.getOrderById(event.getOrderId()); + + if (event.isPaidTransition()) { + log.info("Sending order confirmation email for order: {}", order.getId()); + notificationService.sendOrderConfirmation(order); + notificationService.notifyAdminNewOrder(order); + + } else if (event.isShippingTransition()) { + log.info("Sending shipping confirmation email for order: {}", order.getId()); + notificationService.sendShippingConfirmation(order); + } else if (event.isCompletedTransition()) { + log.info("Sending order status update email for order: {}", order.getId()); + notificationService.sendOrderStatusUpdate(order); + } + } catch (Exception e) { + log.error("Failed to send order status update notification", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/notification/OrderEmailNotificationService.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/notification/OrderEmailNotificationService.java new file mode 100644 index 0000000..ccfef70 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/notification/OrderEmailNotificationService.java @@ -0,0 +1,163 @@ +package com.zenfulcode.commercify.order.infrastructure.notification; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.service.OrderNotificationService; +import com.zenfulcode.commercify.shared.domain.exception.EmailSendingException; +import com.zenfulcode.commercify.shared.infrastructure.service.EmailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OrderEmailNotificationService implements OrderNotificationService { + private final EmailService emailService; + private final TemplateEngine templateEngine; + + @Value("${admin.email}") + private String adminEmail; + + @Value("${admin.order-dashboard}") + private String orderDashboard; + + private static final String ORDER_CONFIRMATION_TEMPLATE = "order/confirmation-email"; + private static final String ORDER_STATUS_UPDATE_TEMPLATE = "order/status-update-email"; + private static final String ORDER_SHIPPING_TEMPLATE = "order/shipping-email"; + private static final String ADMIN_ORDER_TEMPLATE = "order/admin-order-notification"; + + @Override + public void sendOrderConfirmation(Order order) { + try { + Context context = createOrderContext(order); + String emailContent = templateEngine.process(ORDER_CONFIRMATION_TEMPLATE, context); + emailService.sendEmail( + order.getOrderShippingInfo().toCustomerDetails().email(), + "Order Confirmation - #" + order.getId(), + emailContent, true + ); + log.info("Order confirmation email sent for order: {}", order.getId()); + } catch (Exception e) { + log.error("Failed to send order confirmation email", e); + throw new EmailSendingException("Failed to send order confirmation email: " + e.getMessage()); + } + } + + @Override + public void sendOrderStatusUpdate(Order order) { + try { + Context context = createOrderContext(order); + String emailContent = templateEngine.process(ORDER_STATUS_UPDATE_TEMPLATE, context); + emailService.sendEmail( + order.getOrderShippingInfo().toCustomerDetails().email(), + "Order Status Update - #" + order.getId(), + emailContent, true + ); + log.info("Order status update email sent for order: {}", order.getId()); + } catch (Exception e) { + log.error("Failed to send order status update email", e); + throw new EmailSendingException("Failed to send order status update email: " + e.getMessage()); + } + } + + @Override + public void sendShippingConfirmation(Order order) { + try { + Context context = createOrderContext(order); + String emailContent = templateEngine.process(ORDER_SHIPPING_TEMPLATE, context); + emailService.sendEmail( + order.getOrderShippingInfo().toCustomerDetails().email(), + "Order Shipped - #" + order.getId(), + emailContent, true + ); + log.info("Order shipping confirmation email sent for order: {}", order.getId()); + } catch (Exception e) { + log.error("Failed to send shipping confirmation email", e); + throw new EmailSendingException("Failed to send shipping confirmation email: " + e.getMessage()); + } + } + + @Override + public void notifyAdminNewOrder(Order order) { + try { + Context context = createAdminOrderContext(order); + String emailContent = templateEngine.process(ADMIN_ORDER_TEMPLATE, context); + emailService.sendEmail( + adminEmail, + "New Order Received - #" + order.getId(), + emailContent, true + ); + log.info("Admin notification sent for order: {}", order.getId()); + } catch (Exception e) { + log.error("Failed to send admin notification", e); + throw new EmailSendingException("Failed to send admin notification: " + e.getMessage()); + } + } + + private Context createOrderContext(Order order) { + Context context = new Context(Locale.getDefault()); + + List> orderItems = order.getOrderLines().stream() + .map(this::createOrderItemMap) + .toList(); + + Map shippingAddress = new HashMap<>(); + shippingAddress.put("city", order.getOrderShippingInfo().toShippingAddress().city()); + shippingAddress.put("zipCode", order.getOrderShippingInfo().toShippingAddress().zipCode()); + shippingAddress.put("country", order.getOrderShippingInfo().toShippingAddress().country()); + shippingAddress.put("street", order.getOrderShippingInfo().toShippingAddress().street()); + shippingAddress.put("state", order.getOrderShippingInfo().toShippingAddress().state()); + context.setVariable("shippingAddress", shippingAddress); + + Map billingAddress = new HashMap<>(); + billingAddress.put("city", order.getOrderShippingInfo().toBillingAddress().city()); + billingAddress.put("zipCode", order.getOrderShippingInfo().toBillingAddress().zipCode()); + billingAddress.put("country", order.getOrderShippingInfo().toBillingAddress().country()); + billingAddress.put("street", order.getOrderShippingInfo().toBillingAddress().street()); + billingAddress.put("state", order.getOrderShippingInfo().toBillingAddress().state()); + context.setVariable("billingAddress", billingAddress); + + Map details = new HashMap<>(); + details.put("id", order.getId().toString()); + details.put("customerName", order.getOrderShippingInfo().getCustomerName()); + details.put("customerPhone", order.getOrderShippingInfo().getCustomerPhone()); + details.put("customerEmail", order.getOrderShippingInfo().getCustomerEmail()); + details.put("orderNumber", order.getId().toString()); + details.put("status", order.getStatus().toString()); + details.put("createdAt", order.getCreatedAt()); + details.put("currency", order.getCurrency()); + details.put("totalAmount", order.getTotalAmount().getAmount().doubleValue()); + details.put("items", orderItems); + context.setVariable("order", details); + return context; + } + + private Context createAdminOrderContext(Order order) { + Context context = createOrderContext(order); + context.setVariable("adminOrderUrl", String.format("%s/%s", orderDashboard, order.getId().toString())); + return context; + } + + private Map createOrderItemMap(OrderLine orderLine) { + Map items = new HashMap<>(); + + items.put("name", orderLine.getProduct().getName()); + items.put("variant", orderLine.getProductVariant() != null ? + orderLine.getProductVariant().getSku() : ""); + items.put("quantity", orderLine.getQuantity()); + items.put("sku", orderLine.getProductVariant() != null ? + orderLine.getProductVariant().getSku() : orderLine.getProduct().getId().toString()); + items.put("unitPrice", orderLine.getUnitPrice().getAmount().doubleValue()); + items.put("total", orderLine.getTotal().getAmount().doubleValue()); + return items; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderLineRepository.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderLineRepository.java new file mode 100644 index 0000000..a95e020 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderLineRepository.java @@ -0,0 +1,54 @@ +package com.zenfulcode.commercify.order.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.repository.OrderLineRepository; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class JpaOrderLineRepository implements OrderLineRepository { + + private final SpringDataJpaOrderLineRepository repository; + + @Override + public OrderLine save(OrderLine orderLine) { + return repository.save(orderLine); + } + + @Override + public List findByOrderId(OrderId orderId) { + return repository.findByOrderId(orderId); + } + + @Override + public Set findActiveOrdersForProduct( + ProductId productId, Collection statuses) { + return repository.findActiveOrdersForProduct(productId, statuses); + } + + @Override + public Set findActiveOrdersForVariant( + VariantId variantId, Collection statuses) { + return repository.findActiveOrdersForVariant(variantId, statuses); + } + + @Override + public boolean hasActiveOrders(ProductId productId) { + return repository.hasActiveOrders(productId); + } + + @Override + public boolean hasActiveOrdersForVariant(VariantId variantId) { + return repository.hasActiveOrdersForVariant(variantId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderRepository.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderRepository.java new file mode 100644 index 0000000..aad0244 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/JpaOrderRepository.java @@ -0,0 +1,60 @@ +package com.zenfulcode.commercify.order.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.repository.OrderRepository; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JpaOrderRepository implements OrderRepository { + private final SpringDataJpaOrderRepository repository; + + @Override + public Order save(Order order) { + return repository.save(order); + } + + @Override + public Optional findById(OrderId id) { + return repository.findById(id); + } + + @Override + public Page findByUserId(UserId userId, PageRequest pageRequest) { + return repository.findByUserId(userId, pageRequest); + } + + @Override + public Page findAll(PageRequest pageRequest) { + return repository.findAll(pageRequest); + } + + @Override + public boolean existsByIdAndUserId(OrderId id, UserId userId) { + return repository.existsByIdAndUserId(id, userId); + } + + @Override + public boolean existsByUserId(UserId userId) { + return repository.findByUserId(userId, PageRequest.of(0, 1)).hasContent(); + } + + @Override + public Optional calculateTotalRevenue(Instant startDate, Instant endDate) { + return repository.calculateTotalRevenue(startDate, endDate); + } + + @Override + public int countOrders(Instant startDate, Instant endDate) { + return repository.countOrders(startDate, endDate); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderLineRepository.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderLineRepository.java new file mode 100644 index 0000000..a10aedc --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderLineRepository.java @@ -0,0 +1,60 @@ +package com.zenfulcode.commercify.order.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderLine; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Repository +interface SpringDataJpaOrderLineRepository extends JpaRepository { + + List findByOrderId(OrderId orderId); + + @Query(""" + SELECT DISTINCT ol.order FROM OrderLine ol + WHERE ol.product.id = :productId + AND ol.order.status IN :statuses + """) + Set findActiveOrdersForProduct( + @Param("productId") ProductId productId, + @Param("statuses") Collection statuses + ); + + @Query(""" + SELECT DISTINCT ol.order FROM OrderLine ol + JOIN ol.productVariant v + WHERE v.id = :variantId + AND ol.order.status IN :statuses + """) + Set findActiveOrdersForVariant( + @Param("variantId") VariantId variantId, + @Param("statuses") Collection statuses + ); + + @Query(""" + SELECT COUNT(ol) > 0 FROM OrderLine ol + WHERE ol.product.id = :productId + """) + boolean hasActiveOrders( + @Param("productId") ProductId productId + ); + + @Query(""" + SELECT COUNT(ol) > 0 FROM OrderLine ol + JOIN ol.productVariant v + WHERE v.id = :variantId + """) + boolean hasActiveOrdersForVariant( + @Param("variantId") VariantId variantId + ); +} diff --git a/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderRepository.java b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderRepository.java new file mode 100644 index 0000000..c8bfd10 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/order/infrastructure/persistence/SpringDataJpaOrderRepository.java @@ -0,0 +1,43 @@ +package com.zenfulcode.commercify.order.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +@Repository +interface SpringDataJpaOrderRepository extends JpaRepository { + Page findByUserId(UserId userId, Pageable pageable); + + boolean existsByIdAndUserId(OrderId id, UserId userId); + + @Query(""" + SELECT SUM(o.subtotal.amount) + FROM Order o + WHERE o.status = 'COMPLETED' + AND o.createdAt BETWEEN :startDate AND :endDate + """) + Optional calculateTotalRevenue( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate + ); + + @Query(""" + SELECT COUNT(o) + FROM Order o + WHERE o.createdAt BETWEEN :startDate AND :endDate + """) + int countOrders( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate + ); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/command/CapturePaymentCommand.java b/src/main/java/com/zenfulcode/commercify/payment/application/command/CapturePaymentCommand.java new file mode 100644 index 0000000..d063fd5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/command/CapturePaymentCommand.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.payment.application.command; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record CapturePaymentCommand( + OrderId orderId, + Money captureAmount +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/command/InitiatePaymentCommand.java b/src/main/java/com/zenfulcode/commercify/payment/application/command/InitiatePaymentCommand.java new file mode 100644 index 0000000..1f90547 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/command/InitiatePaymentCommand.java @@ -0,0 +1,14 @@ +package com.zenfulcode.commercify.payment.application.command; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderRequest; + +public record InitiatePaymentCommand( + Order order, + PaymentMethod paymentMethod, + PaymentProvider provider, + PaymentProviderRequest providerRequest +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/dto/CaptureAmount.java b/src/main/java/com/zenfulcode/commercify/payment/application/dto/CaptureAmount.java new file mode 100644 index 0000000..2a49674 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/dto/CaptureAmount.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.payment.application.dto; + +import java.math.BigDecimal; + +public record CaptureAmount( + BigDecimal capturedAmount, + BigDecimal remainingAmount, + String currency +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/dto/CapturedPayment.java b/src/main/java/com/zenfulcode/commercify/payment/application/dto/CapturedPayment.java new file mode 100644 index 0000000..e20aa55 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/dto/CapturedPayment.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.payment.application.dto; + +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; + +public record CapturedPayment( + TransactionId transactionId, + CaptureAmount captureAmount, + boolean isFullyCaptured +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/dto/InitializedPayment.java b/src/main/java/com/zenfulcode/commercify/payment/application/dto/InitializedPayment.java new file mode 100644 index 0000000..5805cdd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/dto/InitializedPayment.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.payment.application.dto; + +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; + +import java.util.Map; + +public record InitializedPayment( + PaymentId paymentId, + String redirectUrl, + Map additionalData +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/service/MobilepayWebhookService.java b/src/main/java/com/zenfulcode/commercify/payment/application/service/MobilepayWebhookService.java new file mode 100644 index 0000000..0f53e1a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/service/MobilepayWebhookService.java @@ -0,0 +1,47 @@ +package com.zenfulcode.commercify.payment.application.service; + +import com.zenfulcode.commercify.api.payment.mapper.PaymentDtoMapper; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderFactory; +import com.zenfulcode.commercify.payment.domain.service.provider.MobilepayProviderService; +import com.zenfulcode.commercify.payment.domain.valueobject.WebhookRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MobilepayWebhookService { + private final PaymentProviderFactory providerFactory; + private final PaymentDtoMapper mapper; + + @Transactional + public WebhookPayload authenticate(PaymentProvider provider, WebhookRequest request) { + MobilepayProviderService paymentProvider = (MobilepayProviderService) providerFactory.getProvider(provider); + + String contentSha256 = request.headers().get("x-ms-content-sha256"); + String authorization = request.headers().get("authorization"); + String date = request.headers().get("x-ms-date"); + + paymentProvider.authenticateWebhook(date, contentSha256, authorization, request.body()); + return mapper.toWebhookPayload(request); + } + + @Transactional + public void registerWebhook(PaymentProvider provider, String callbackUrl) { + providerFactory.getProvider(provider).registerWebhook(callbackUrl); + } + + @Transactional + public void deleteWebhook(PaymentProvider provider, String webhookId) { + providerFactory.getProvider(provider).deleteWebhook(webhookId); + } + + @Transactional(readOnly = true) + public Object getWebhooks(PaymentProvider provider) { + return providerFactory.getProvider(provider).getWebhooks(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/application/service/PaymentApplicationService.java b/src/main/java/com/zenfulcode/commercify/payment/application/service/PaymentApplicationService.java new file mode 100644 index 0000000..97fb755 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/application/service/PaymentApplicationService.java @@ -0,0 +1,106 @@ +package com.zenfulcode.commercify.payment.application.service; + +import com.zenfulcode.commercify.payment.application.command.CapturePaymentCommand; +import com.zenfulcode.commercify.payment.application.command.InitiatePaymentCommand; +import com.zenfulcode.commercify.payment.application.dto.CaptureAmount; +import com.zenfulcode.commercify.payment.application.dto.CapturedPayment; +import com.zenfulcode.commercify.payment.application.dto.InitializedPayment; +import com.zenfulcode.commercify.payment.domain.exception.PaymentProviderNotFoundException; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.service.PaymentDomainService; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderFactory; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderService; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderResponse; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import com.zenfulcode.commercify.payment.infrastructure.webhook.WebhookHandler; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.service.DefaultDomainEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +public class PaymentApplicationService { + private final PaymentDomainService paymentDomainService; + private final PaymentProviderFactory providerFactory; + private final DefaultDomainEventPublisher eventPublisher; + private final WebhookHandler webhookHandler; + + @Transactional + public InitializedPayment initiatePayment(InitiatePaymentCommand command) { + // Get the appropriate provider service + PaymentProviderService providerService = providerFactory.getProvider(command.provider()); + + // Validate provider-specific request + providerService.validateRequest(command.providerRequest()); + + // Create domain payment entity + Payment payment = paymentDomainService.createPayment( + command.order(), + command.paymentMethod(), + command.provider() + ); + + // Initiate payment with provider + PaymentProviderResponse providerResponse = providerService.initiatePayment( + payment, + command.order().getId(), + command.providerRequest() + ); + + // Update payment with provider reference + paymentDomainService.updateProviderReference(payment, providerResponse.providerReference()); + + // Publish events + eventPublisher.publish(payment.getDomainEvents()); + + // Return response + return new InitializedPayment( + payment.getId(), + providerResponse.redirectUrl(), + providerResponse.additionalData() + ); + } + + @Transactional + public void handlePaymentCallback(PaymentProvider provider, WebhookPayload payload) { + Payment payment = paymentDomainService.getPaymentByProviderReference(payload.getPaymentReference()); + webhookHandler.handleWebhook(provider, payload, payment); + + eventPublisher.publish(payment.getDomainEvents()); + } + + // TODO: Make sure the capture currency is the same as the payment currency + @Transactional + public CapturedPayment capturePayment(CapturePaymentCommand command) { + Payment payment = paymentDomainService.getPaymentByOrderId(command.orderId()); + + Money captureAmount = command.captureAmount() == null ? payment.getAmount() : command.captureAmount(); + + paymentDomainService.capturePayment(payment, TransactionId.generate(), captureAmount); + + // Publish events + eventPublisher.publish(payment.getDomainEvents()); + + // This is going to be used for partial captures, which is not implemented yet + BigDecimal remainingAmount = payment.getAmount().subtract(captureAmount).getAmount(); + CaptureAmount captureAmountDto = new CaptureAmount(captureAmount.getAmount(), remainingAmount, captureAmount.getCurrency()); + boolean isFullyCaptured = payment.getStatus() == PaymentStatus.CAPTURED; + + return new CapturedPayment(payment.getTransactionId(), captureAmountDto, isFullyCaptured); + } + + public PaymentProvider getPaymentProvider(String provider) { + try { + return PaymentProvider.valueOf(provider.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PaymentProviderNotFoundException(provider); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/IssuedRefundEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/IssuedRefundEvent.java new file mode 100644 index 0000000..4a57c2f --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/IssuedRefundEvent.java @@ -0,0 +1,51 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.refund.RefundReason; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when payment is refunded + */ +@Getter +public class IssuedRefundEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final Money refundAmount; + private final RefundReason reason; + private final String notes; + private final boolean isFullRefund; + private final Instant refundedAt; + + public IssuedRefundEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + Money refundAmount, + RefundReason reason, + String notes, + boolean isFullRefund + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.refundAmount = refundAmount; + this.reason = reason; + this.notes = notes; + this.isFullRefund = isFullRefund; + this.refundedAt = Instant.now(); + } + + @Override + public String getEventType() { + return "ISSUED_REFUND"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCancelledEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCancelledEvent.java new file mode 100644 index 0000000..f3da3c1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCancelledEvent.java @@ -0,0 +1,40 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when payment is cancelled + */ +@Getter +public class PaymentCancelledEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final String reason; + private final Instant cancelledAt; + + public PaymentCancelledEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + String reason + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.reason = reason; + this.cancelledAt = Instant.now(); + } + + @Override + public String getEventType() { + return "PAYMENT_CANCELLED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCapturedEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCapturedEvent.java new file mode 100644 index 0000000..a6c6cb6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCapturedEvent.java @@ -0,0 +1,45 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when payment is captured + */ +@Getter +public class PaymentCapturedEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final Money amount; + private final TransactionId transactionId; + private final Instant capturedAt; + + public PaymentCapturedEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + Money amount, + TransactionId transactionId + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.amount = amount; + this.transactionId = transactionId; + this.capturedAt = Instant.now(); + } + + @Override + public String getEventType() { + return "PAYMENT_CAPTURED"; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCreatedEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCreatedEvent.java new file mode 100644 index 0000000..0c26f5c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentCreatedEvent.java @@ -0,0 +1,49 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when a payment is created + */ +@Getter +public class PaymentCreatedEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final Money amount; + private final PaymentMethod paymentMethod; + private final PaymentProvider provider; + private final Instant createdAt; + + public PaymentCreatedEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + Money amount, + PaymentMethod paymentMethod, + PaymentProvider provider + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.amount = amount; + this.paymentMethod = paymentMethod; + this.provider = provider; + this.createdAt = Instant.now(); + } + + @Override + public String getEventType() { + return "PAYMENT_CREATED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentFailedEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentFailedEvent.java new file mode 100644 index 0000000..1b78b82 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentFailedEvent.java @@ -0,0 +1,45 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.FailureReason; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when payment fails + */ +@Getter +public class PaymentFailedEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final FailureReason failureReason; + private final PaymentStatus status; + private final Instant failedAt; + + public PaymentFailedEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + FailureReason failureReason, + PaymentStatus status + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.failureReason = failureReason; + this.status = status; + this.failedAt = Instant.now(); + } + + @Override + public String getEventType() { + return "PAYMENT_FAILED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentReservedEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentReservedEvent.java new file mode 100644 index 0000000..d534913 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentReservedEvent.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import lombok.Getter; + +@Getter +public class PaymentReservedEvent extends DomainEvent { + private final PaymentId paymentId; + private final OrderId orderId; + private final TransactionId transactionId; + + public PaymentReservedEvent(Object source, PaymentId paymentId, OrderId orderId, TransactionId transactionId) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.transactionId = transactionId; + } + + @Override + public String getEventType() { + return "PAYMENT_RESERVED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentStatusChangedEvent.java b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentStatusChangedEvent.java new file mode 100644 index 0000000..c47d77b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/event/PaymentStatusChangedEvent.java @@ -0,0 +1,57 @@ +package com.zenfulcode.commercify.payment.domain.event; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +import java.time.Instant; + +/** + * Event raised when payment status changes + */ +@Getter +public class PaymentStatusChangedEvent extends DomainEvent { + // Getters + @AggregateId + private final PaymentId paymentId; + private final OrderId orderId; + private final PaymentStatus oldStatus; + private final PaymentStatus newStatus; + @AggregateId + private final TransactionId transactionId; + private final Instant changedAt; + + public PaymentStatusChangedEvent( + Object source, + PaymentId paymentId, + OrderId orderId, + PaymentStatus oldStatus, + PaymentStatus newStatus, + TransactionId transactionId + ) { + super(source); + this.paymentId = paymentId; + this.orderId = orderId; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.transactionId = transactionId; + this.changedAt = Instant.now(); + } + + public boolean isCompletedTransition() { + return newStatus == PaymentStatus.CAPTURED; + } + + public boolean isFailedTransition() { + return newStatus == PaymentStatus.FAILED; + } + + @Override + public String getEventType() { + return "PAYMENT_STATUS_CHANGED"; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/InvalidPaymentStateException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/InvalidPaymentStateException.java new file mode 100644 index 0000000..9e5332d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/InvalidPaymentStateException.java @@ -0,0 +1,36 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import lombok.Getter; + +/** + * Thrown when payment is in invalid state for requested operation + */ +@Getter +public class InvalidPaymentStateException extends DomainException { + private final PaymentId paymentId; + private final PaymentStatus currentStatus; + private final PaymentStatus targetStatus; + private final String reason; + + public InvalidPaymentStateException( + PaymentId paymentId, + PaymentStatus currentStatus, + PaymentStatus targetStatus, + String reason + ) { + super(String.format( + "Cannot transition payment %s from %s to %s: %s", + paymentId, + currentStatus, + targetStatus, + reason + )); + this.paymentId = paymentId; + this.currentStatus = currentStatus; + this.targetStatus = targetStatus; + this.reason = reason; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentNotFoundException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentNotFoundException.java new file mode 100644 index 0000000..d42d679 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentNotFoundException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.EntityNotFoundException; + +public class PaymentNotFoundException extends EntityNotFoundException { + public PaymentNotFoundException(Object entityId) { + super("Payment", entityId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProcessingException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProcessingException.java new file mode 100644 index 0000000..b4d21ab --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProcessingException.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import lombok.Getter; + +@Getter +public class PaymentProcessingException extends DomainException { + private final String providerReference; + + public PaymentProcessingException(String message, Throwable cause) { + this(message, null, cause); + } + + public PaymentProcessingException(String message, String providerReference, Throwable cause) { + super(message, cause); + this.providerReference = providerReference; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProviderNotFoundException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProviderNotFoundException.java new file mode 100644 index 0000000..398613b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentProviderNotFoundException.java @@ -0,0 +1,17 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.EntityNotFoundException; +import lombok.Getter; + +/** + * Thrown when payment provider is not found or not supported + */ +@Getter +public class PaymentProviderNotFoundException extends EntityNotFoundException { + private final String provider; + + public PaymentProviderNotFoundException(String provider) { + super("PaymentProvider", provider); + this.provider = provider; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentValidationException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentValidationException.java new file mode 100644 index 0000000..f9e27ad --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/PaymentValidationException.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +/** + * Thrown when payment validation fails + */ +public class PaymentValidationException extends DomainValidationException { + public PaymentValidationException(String message, List violations) { + super(message, violations); + } + + public PaymentValidationException(String message) { + super(message, List.of(message)); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookProcessingException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookProcessingException.java new file mode 100644 index 0000000..87b02ac --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookProcessingException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class WebhookProcessingException extends DomainException { + public WebhookProcessingException(String message) { + super("Webhook validation mismatch: " + message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookValidationException.java b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookValidationException.java new file mode 100644 index 0000000..d3a06ac --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/exception/WebhookValidationException.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.payment.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +public class WebhookValidationException extends DomainValidationException { + + public WebhookValidationException(String message, List violations) { + super(message, violations); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/model/FailureReason.java b/src/main/java/com/zenfulcode/commercify/payment/domain/model/FailureReason.java new file mode 100644 index 0000000..20fa663 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/model/FailureReason.java @@ -0,0 +1,21 @@ +package com.zenfulcode.commercify.payment.domain.model; + +import lombok.Getter; + +@Getter +public enum FailureReason { + INSUFFICIENT_FUNDS("Insufficient funds"), + INVALID_PAYMENT_METHOD("Invalid payment method"), + PAYMENT_PROVIDER_ERROR("Payment provider error"), + PAYMENT_METHOD_ERROR("Payment method error"), + PAYMENT_PROCESSING_ERROR("Payment processing error"), + PAYMENT_EXPIRED("Payment expired"), + PAYMENT_TERMINATED("Payment terminated"), + UNKNOWN("Unknown"); + + private final String reason; + + FailureReason(String reason) { + this.reason = reason; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/model/Payment.java b/src/main/java/com/zenfulcode/commercify/payment/domain/model/Payment.java new file mode 100644 index 0000000..bb56019 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/model/Payment.java @@ -0,0 +1,233 @@ +package com.zenfulcode.commercify.payment.domain.model; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.payment.domain.event.*; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.payment.domain.valueobject.refund.RefundReason; +import com.zenfulcode.commercify.shared.domain.model.AggregateRoot; +import com.zenfulcode.commercify.shared.domain.model.Money; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "payments") +@Getter +@Setter +@NoArgsConstructor +public class Payment extends AggregateRoot { + + @EmbeddedId + private PaymentId id; + + @OneToOne(orphanRemoval = true) + @JoinColumns({ + @JoinColumn(name = "order_id", referencedColumnName = "id") + }) + private Order order; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "amount")), + @AttributeOverride(name = "currency", column = @Column(name = "currency")) + }) + private Money amount; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_method", nullable = false) + private PaymentMethod paymentMethod; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_provider", nullable = false) + private PaymentProvider provider; + + @Column(name = "provider_reference") + private String providerReference; + + @Embedded + private TransactionId transactionId; + + @Column(name = "error_message") + private String errorMessage; + + @ElementCollection + @CollectionTable( + name = "payment_attempts", + joinColumns = @JoinColumn(name = "payment_id", referencedColumnName = "id") + ) + private List paymentAttempts = new ArrayList<>(); + + @Column(name = "retry_count") + private int retryCount = 0; + + @Column(name = "max_retries") + private int maxRetries = 3; + + @CreationTimestamp + @Column(name = "created_at") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + // Factory method for creating new payments + public static Payment create( + Money amount, + PaymentMethod paymentMethod, + PaymentProvider provider + ) { + Payment payment = new Payment(); + payment.id = PaymentId.generate(); + payment.amount = amount; + payment.paymentMethod = paymentMethod; + payment.provider = provider; + payment.status = PaymentStatus.PENDING; + + return payment; + } + + // Domain methods + public void markAsCaptured(TransactionId transactionId, Money capturedAmount) { + this.transactionId = transactionId; + this.completedAt = Instant.now(); + + updateStatus(PaymentStatus.CAPTURED); + + registerEvent(new PaymentCapturedEvent( + this, + id, + order.getId(), + capturedAmount, + transactionId + )); + } + + public void markAsFailed(FailureReason reason, PaymentStatus status) { + this.errorMessage = reason.getReason(); + recordPaymentAttempt(false, reason.getReason()); + + updateStatus(status); + + registerEvent(new PaymentFailedEvent( + this, + this.id, + order.getId(), + reason, + status + )); + } + + public void processRefund(Money refundAmount, RefundReason reason, String notes) { + // For full refunds + if (refundAmount.equals(this.amount)) { + updateStatus(PaymentStatus.REFUNDED); + } else { + updateStatus(PaymentStatus.PARTIALLY_REFUNDED); + } + + registerEvent(new IssuedRefundEvent( + this, + this.id, + order.getId(), + refundAmount, + reason, + notes, + refundAmount.equals(this.amount) + )); + } + + public void cancel() { + updateStatus(PaymentStatus.CANCELLED); + + registerEvent(new PaymentCancelledEvent( + this, + this.id, + order.getId(), + "Payment cancelled" + )); + } + + public void updateProviderReference(String reference) { + this.providerReference = reference; + } + + public boolean canRetry() { + return this.status == PaymentStatus.FAILED && + this.retryCount < this.maxRetries; + } + + public void recordPaymentAttempt(boolean successful, String details) { + PaymentAttempt attempt = new PaymentAttempt( + Instant.now(), + successful, + details + ); + + this.paymentAttempts.add(attempt); + if (!successful) { + this.retryCount++; + } + } + + public void reserve() { + updateStatus(PaymentStatus.RESERVED); + + registerEvent(new PaymentReservedEvent( + this, + this.id, + order.getId(), + transactionId + )); + } + + private void updateStatus(PaymentStatus newStatus) { + PaymentStatus oldStatus = this.status; + this.status = newStatus; + + registerEvent(new PaymentStatusChangedEvent( + this, + this.id, + order.getId(), + oldStatus, + status, + null + )); + } + + @Embeddable + @Getter + @NoArgsConstructor + public static class PaymentAttempt { + @Column(name = "attempt_time") + private Instant attemptTime; + + @Column(name = "successful") + private boolean successful; + + @Column(name = "details") + private String details; + + public PaymentAttempt(Instant attemptTime, boolean successful, String details) { + this.attemptTime = attemptTime; + this.successful = successful; + this.details = details; + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentMethod.java b/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentMethod.java new file mode 100644 index 0000000..4cc1d2d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentMethod.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.payment.domain.model; + +public enum PaymentMethod { + WALLET, // Payment was made using a wallet + CARD // Payment was made using a card +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentProvider.java b/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentProvider.java new file mode 100644 index 0000000..fead84d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/model/PaymentProvider.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.payment.domain.model; + +public enum PaymentProvider { + MOBILEPAY, // Payment was made using MobilePay + STRIPE, // Payment was made using Stripe +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/model/WebhookConfig.java b/src/main/java/com/zenfulcode/commercify/payment/domain/model/WebhookConfig.java new file mode 100644 index 0000000..2356afc --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/model/WebhookConfig.java @@ -0,0 +1,44 @@ +package com.zenfulcode.commercify.payment.domain.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.proxy.HibernateProxy; + +import java.util.Objects; + +@Entity +@Table(name = "webhook_config") +@Builder +@Getter +@Setter +@ToString +@RequiredArgsConstructor +@AllArgsConstructor +public class WebhookConfig { + @Id + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private PaymentProvider provider; + + @Column(name = "callback_url", nullable = false) + private String callbackUrl; + + @Column(name = "secret", nullable = false) + private String secret; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + WebhookConfig that = (WebhookConfig) o; + return getProvider() != null && Objects.equals(getProvider(), that.getProvider()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/repository/PaymentRepository.java b/src/main/java/com/zenfulcode/commercify/payment/domain/repository/PaymentRepository.java new file mode 100644 index 0000000..aa5b329 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/repository/PaymentRepository.java @@ -0,0 +1,23 @@ +package com.zenfulcode.commercify.payment.domain.repository; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.util.Optional; + +public interface PaymentRepository { + Payment save(Payment payment); + + Optional findById(PaymentId id); + + Optional findByProviderReference(String providerReference); + + Optional findByTransactionId(String transactionId); + + Optional findByOrderId(OrderId orderId); + + Page findAll(PageRequest pageRequest); +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/repository/WebhookConfigRepository.java b/src/main/java/com/zenfulcode/commercify/payment/domain/repository/WebhookConfigRepository.java new file mode 100644 index 0000000..32facb3 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/repository/WebhookConfigRepository.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.payment.domain.repository; + +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.model.WebhookConfig; + +import java.util.Optional; + +public interface WebhookConfigRepository { + WebhookConfig save(WebhookConfig config); + + Optional findByProvider(PaymentProvider provider); +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentDomainService.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentDomainService.java new file mode 100644 index 0000000..fb3dcd7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentDomainService.java @@ -0,0 +1,135 @@ +package com.zenfulcode.commercify.payment.domain.service; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.event.PaymentCreatedEvent; +import com.zenfulcode.commercify.payment.domain.exception.PaymentNotFoundException; +import com.zenfulcode.commercify.payment.domain.model.FailureReason; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.repository.PaymentRepository; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.TransactionId; +import com.zenfulcode.commercify.payment.domain.valueobject.refund.RefundRequest; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PaymentDomainService { + private final PaymentValidationService validationService; + private final PaymentRepository paymentRepository; + + /** + * Creates a new payment for an order + */ + public Payment createPayment(Order order, PaymentMethod paymentMethod, PaymentProvider provider) { + validationService.validateCreatePayment(order, paymentMethod, provider); + + // Create payment + Payment payment = Payment.create( + order.getTotalAmount(), + paymentMethod, + provider + ); + + payment.setOrder(order); + + payment.registerEvent(new PaymentCreatedEvent( + this, + payment.getId(), + payment.getOrder().getId(), + payment.getAmount(), + payment.getPaymentMethod(), + payment.getProvider() + )); + + return payment; + } + + /** + * Processes a successful payment capture + */ + public void capturePayment(Payment payment, TransactionId transactionId, Money capturedAmount) { + validationService.validatePaymentCapture(payment, capturedAmount); + + payment.markAsCaptured(transactionId, capturedAmount); + + paymentRepository.save(payment); + } + + /** + * Handles payment failures + */ + public void failPayment(Payment payment, FailureReason failureReason, PaymentStatus status) { + // Validate current state + validationService.validateStatusTransition(payment, status); + + payment.markAsFailed(failureReason, status); + + paymentRepository.save(payment); + } + + /** + * Processes payment refunds + */ + public void refundPayment(Payment payment, RefundRequest refundRequest) { + // Validate refund request + validationService.validateRefundRequest(payment, refundRequest); + + // Process refund + payment.processRefund( + payment.getAmount(), + refundRequest.reason(), + refundRequest.notes() + ); + + paymentRepository.save(payment); + } + + /** + * Cancels a reserved payment + */ + public void cancelPayment(Payment payment) { + // Validate cancellation + validationService.validateStatusTransition(payment, PaymentStatus.CANCELLED); + + payment.cancel(); + + paymentRepository.save(payment); + } + + public void authorizePayment(Payment payment) { + validationService.validateStatusTransition(payment, PaymentStatus.RESERVED); + + payment.reserve(); + + paymentRepository.save(payment); + } + + public void updateProviderReference(Payment payment, String providerReference) { + payment.updateProviderReference(providerReference); + paymentRepository.save(payment); + } + + /** + * Get payment by ID + */ + public Payment getPaymentById(PaymentId paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException(paymentId)); + } + + public Payment getPaymentByProviderReference(String providerReference) { + return paymentRepository.findByProviderReference(providerReference) + .orElseThrow(() -> new PaymentNotFoundException(providerReference)); + } + + public Payment getPaymentByOrderId(OrderId orderId) { + return paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new PaymentNotFoundException(orderId)); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderFactory.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderFactory.java new file mode 100644 index 0000000..b341160 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderFactory.java @@ -0,0 +1,45 @@ +package com.zenfulcode.commercify.payment.domain.service; + +import com.zenfulcode.commercify.payment.domain.exception.PaymentProviderNotFoundException; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PaymentProviderFactory { + private final Map providerServices = new HashMap<>(); + + public PaymentProviderService getProvider(PaymentProvider provider) { + PaymentProviderService service = providerServices.get(provider); + if (service == null) { + throw new PaymentProviderNotFoundException("Payment provider not found: " + provider); + } + return service; + } + + public List getAvailableProviders() { + return providerServices.values().stream() + .map(PaymentProviderService::getProviderConfig) + .filter(PaymentProviderConfig::isActive) + .collect(Collectors.toList()); + } + + public void registerProvider(PaymentProvider provider, PaymentProviderService service) { + providerServices.put(provider, service); + } + + public void unregisterProvider(PaymentProvider provider) { + providerServices.remove(provider); + } + + public boolean supportsProvider(PaymentProvider provider) { + return providerServices.containsKey(provider); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderService.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderService.java new file mode 100644 index 0000000..e4075c2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentProviderService.java @@ -0,0 +1,61 @@ +package com.zenfulcode.commercify.payment.domain.service; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderConfig; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderRequest; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentProviderResponse; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; + +import java.util.Set; + +/** + * Base interface for all payment provider integrations + */ +public interface PaymentProviderService { + /** + * Initialize a payment with the provider + */ + PaymentProviderResponse initiatePayment(Payment payment, OrderId orderId, PaymentProviderRequest request); + + /** + * Handle provider webhook callbacks + */ + void handleCallback(Payment payment, WebhookPayload payload); + + /** + * Get supported payment methods + */ + Set getSupportedPaymentMethods(); + + /** + * Get provider-specific configuration + */ + PaymentProviderConfig getProviderConfig(); + + /** + * Validate provider-specific request + */ + void validateRequest(PaymentProviderRequest request); + + /** + * Check if provider supports payment method + */ + boolean supportsPaymentMethod(PaymentMethod method); + + /** + * Register a webhook with the provider + */ + void registerWebhook(String callbackUrl); + + /** + * Delete a webhook with the provider + */ + void deleteWebhook(String webhookId); + + /** + * Get all webhooks registered with the provider + */ + Object getWebhooks(); +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentStateFlow.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentStateFlow.java new file mode 100644 index 0000000..a6a8596 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentStateFlow.java @@ -0,0 +1,111 @@ +package com.zenfulcode.commercify.payment.domain.service; + +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStateMetadata; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Set; + +@Component +public class PaymentStateFlow { + private final EnumMap> validTransitions; + + public PaymentStateFlow() { + this.validTransitions = new EnumMap<>(PaymentStatus.class); + + // Initial state -> PENDING + validTransitions.put(PaymentStatus.PENDING, Set.of( + PaymentStatus.FAILED, + PaymentStatus.RESERVED, + PaymentStatus.TERMINATED + )); + + validTransitions.put(PaymentStatus.RESERVED, Set.of( + PaymentStatus.CAPTURED, + PaymentStatus.CANCELLED, + PaymentStatus.EXPIRED, + PaymentStatus.FAILED, + PaymentStatus.PARTIALLY_REFUNDED, + PaymentStatus.REFUNDED + )); + + // TODO: Unsure about this one + // CAPTURED -> REFUNDED or PARTIALLY_REFUNDED + validTransitions.put(PaymentStatus.CAPTURED, Set.of( + PaymentStatus.REFUNDED, + PaymentStatus.PARTIALLY_REFUNDED + )); + + // PARTIALLY_REFUNDED -> REFUNDED + validTransitions.put(PaymentStatus.PARTIALLY_REFUNDED, Set.of( + PaymentStatus.REFUNDED + )); + + // Terminal states have no further transitions + validTransitions.put(PaymentStatus.REFUNDED, Set.of()); + validTransitions.put(PaymentStatus.CANCELLED, Set.of()); + validTransitions.put(PaymentStatus.EXPIRED, Set.of()); + validTransitions.put(PaymentStatus.TERMINATED, Set.of()); + validTransitions.put(PaymentStatus.FAILED, Set.of()); + } + + + /** + * Check if a state transition is valid + */ + public boolean canTransitionTo(PaymentStatus currentState, PaymentStatus targetState) { + final PaymentStateMetadata metadata = getStateMetadata(currentState); + return metadata.canTransitionTo(targetState); + } + + /** + * Get valid next states for a given state + */ + private Set getValidTransitions(PaymentStatus currentState) { + return validTransitions.getOrDefault(currentState, Set.of()); + } + + /** + * Check if a state is terminal + */ + public boolean isTerminalState(PaymentStatus state) { + return validTransitions.get(state).isEmpty(); + } + + /** + * Get state transition diagram for documentation + */ + public String getStateTransitionDiagram() { + StringBuilder diagram = new StringBuilder(); + diagram.append("Payment State Transitions:\n"); + diagram.append("------------------------\n"); + + validTransitions.forEach((state, transitions) -> { + PaymentStateMetadata metadata = getStateMetadata(state); + + if (metadata.hasTransitions()) { + diagram.append(state).append(" -> "); + diagram.append(String.join(" | ", metadata.validTransitions().stream() + .map(PaymentStatus::name) + .toList())); + diagram.append("\n"); + } else { + diagram.append(state).append(" (Terminal State)\n"); + } + }); + + return diagram.toString(); + } + + /** + * Get state metadata including whether it's terminal and valid transitions + */ + public PaymentStateMetadata getStateMetadata(PaymentStatus state) { + return new PaymentStateMetadata( + state, + isTerminalState(state), + getValidTransitions(state) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentValidationService.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentValidationService.java new file mode 100644 index 0000000..0928b5c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/PaymentValidationService.java @@ -0,0 +1,151 @@ +package com.zenfulcode.commercify.payment.domain.service; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.order.domain.service.OrderStateFlow; +import com.zenfulcode.commercify.payment.domain.exception.InvalidPaymentStateException; +import com.zenfulcode.commercify.payment.domain.exception.PaymentValidationException; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentStatus; +import com.zenfulcode.commercify.payment.domain.valueobject.refund.RefundRequest; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class PaymentValidationService { + private final PaymentStateFlow stateFlow; + + private static final Set SUPPORTED_CURRENCIES = Set.of("USD", "EUR", "GBP", "DKK"); + private final PaymentProviderFactory paymentProviderFactory; + private final OrderStateFlow orderStateFlow; + + /** + * Validates payment creation + */ + public void validateCreatePayment(Order order, PaymentMethod paymentMethod, PaymentProvider provider) { + List violations = new ArrayList<>(); + + validateAmount(order.getTotalAmount(), violations); + + // Validate payment method + validatePaymentMethod(paymentMethod, provider, violations); + + // Validate order state + validateOrderForPayment(order, violations); + + if (!violations.isEmpty()) { + throw new PaymentValidationException("Payment validation failed", violations); + } + } + + /** + * Validates payment for capture + */ + public void validatePaymentCapture(Payment payment, Money captureAmount) { + List violations = new ArrayList<>(); + + // Validate payment state + if (payment.getStatus() != PaymentStatus.RESERVED) { + violations.add("Payment must be in RESERVED state to be captured"); + } + + // Validate capture amount matches payment amount + if (!captureAmount.equals(payment.getAmount())) { + violations.add("Capture amount must match payment amount"); + } + + if (!violations.isEmpty()) { + throw new PaymentValidationException("Payment capture validation failed", violations); + } + } + + /** + * Validates payment for refund + */ + public void validateRefundRequest(Payment payment, RefundRequest refundRequest) { + List violations = new ArrayList<>(); + + // Validate payment state + if (payment.getStatus() != PaymentStatus.CAPTURED) { + violations.add("Only paid payments can be refunded"); + } + + if (refundRequest.reason() == null) { + violations.add("Refund reason is required"); + } + + // Validate refund amount + validateRefundAmount(payment, refundRequest.amount(), violations); + + if (!violations.isEmpty()) { + throw new PaymentValidationException("Refund validation failed", violations); + } + } + + /** + * Validates payment status transition + */ + public void validateStatusTransition(Payment payment, PaymentStatus newStatus) { + if (!stateFlow.canTransitionTo(payment.getStatus(), newStatus)) { + throw new InvalidPaymentStateException( + payment.getId(), + payment.getStatus(), + newStatus, + "Invalid payment status transition" + ); + } + } + + private void validateAmount(Money amount, List violations) { + // Validate currency + if (!SUPPORTED_CURRENCIES.contains(amount.getCurrency())) { + violations.add("Unsupported currency: " + amount.getCurrency()); + } + } + + private void validatePaymentMethod(PaymentMethod method, PaymentProvider provider, List violations) { + PaymentProviderService providerService = paymentProviderFactory.getProvider(provider); + + // Validate payment method is supported by provider + if (!providerService.supportsPaymentMethod(method)) { + violations.add("Payment method " + method + " is not supported by provider " + provider); + } + } + + private void validateOrderForPayment(Order order, List violations) { + // Validate order has total amount + if (order.getTotalAmount() == null || order.getTotalAmount().isZero()) { + violations.add("Order must have a valid total amount"); + } + + // Validate order status allows payment + if (!orderStateFlow.canTransition(order.getStatus(), OrderStatus.PAID)) { + violations.add("Order status does not allow payment"); + } + } + + private void validateRefundAmount(Payment payment, Money refundAmount, List violations) { + // Validate currency matches + if (!refundAmount.getCurrency().equals(payment.getAmount().getCurrency())) { + violations.add("Refund currency must match payment currency"); + } + + // Validate refund amount does not exceed payment amount + if (refundAmount.isGreaterThan(payment.getAmount())) { + violations.add("Refund amount cannot exceed payment amount"); + } + + // Validate refund amount is positive + if (refundAmount.isZero() || refundAmount.isNegative()) { + violations.add("Refund amount must be positive"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/service/provider/MobilepayProviderService.java b/src/main/java/com/zenfulcode/commercify/payment/domain/service/provider/MobilepayProviderService.java new file mode 100644 index 0000000..9b8d9bf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/service/provider/MobilepayProviderService.java @@ -0,0 +1,152 @@ +package com.zenfulcode.commercify.payment.domain.service.provider; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.exception.PaymentValidationException; +import com.zenfulcode.commercify.payment.domain.exception.WebhookProcessingException; +import com.zenfulcode.commercify.payment.domain.model.FailureReason; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.service.PaymentDomainService; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderService; +import com.zenfulcode.commercify.payment.domain.valueobject.*; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.MobilepayWebhookPayload; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import com.zenfulcode.commercify.payment.infrastructure.gateway.MobilepayCreatePaymentRequest; +import com.zenfulcode.commercify.payment.infrastructure.gateway.MobilepayPaymentResponse; +import com.zenfulcode.commercify.payment.infrastructure.gateway.client.MobilepayClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MobilepayProviderService implements PaymentProviderService { + private final MobilepayClient mobilePayClient; + private final PaymentDomainService paymentService; + + @Override + public PaymentProviderResponse initiatePayment(Payment payment, OrderId orderId, PaymentProviderRequest request) { + // Convert to MobilePay specific request + MobilepayPaymentRequest mobilePayRequest = (MobilepayPaymentRequest) request; + + // Create payment through MobilePay client + MobilepayPaymentResponse response = mobilePayClient.createPayment( + new MobilepayCreatePaymentRequest( + payment.getAmount(), + mobilePayRequest.getPaymentMethod(), + mobilePayRequest.phoneNumber(), + mobilePayRequest.returnUrl(), + orderId.toString() + ) + ); + + // Return provider response + return new PaymentProviderResponse( + response.reference(), + response.redirectUrl(), + Map.of("providerReference", response.reference()) + ); + } + + @Override + @Transactional + public void handleCallback(Payment payment, WebhookPayload payload) { + MobilepayWebhookPayload webhookPayload = (MobilepayWebhookPayload) payload; + + log.info("Handling MobilePay webhook: {}", webhookPayload); + + if (!webhookPayload.isValid()) { + log.error("Invalid webhook payload: {}", webhookPayload); + throw new WebhookProcessingException("Invalid webhook payload"); + } + + switch (payload.getEventType()) { + case "CREATED": + break; + case "AUTHORIZED": + paymentService.authorizePayment(payment); + break; + case "ABORTED", "TERMINATED": + paymentService.failPayment(payment, FailureReason.PAYMENT_TERMINATED, PaymentStatus.TERMINATED); + break; + case "CANCELLED": +// paymentService.cancelPayment(payment); + break; + case "EXPIRED": + paymentService.failPayment(payment, FailureReason.PAYMENT_EXPIRED, PaymentStatus.EXPIRED); + break; + case "CAPTURED": +// double amount = webhookPayload.amount().value() / 100.0; +// Money captureAmount = Money.of(amount, webhookPayload.amount().currency()); +// paymentService.capturePayment(payment, TransactionId.generate(), captureAmount); + break; + case "REFUNDED": + log.info("Handle refund: {}", payment.getId()); + break; + } + } + + @Override + public Set getSupportedPaymentMethods() { + return Set.of(PaymentMethod.WALLET); + } + + @Override + public PaymentProviderConfig getProviderConfig() { + return new PaymentProviderConfig( + PaymentProvider.MOBILEPAY, + true, + new HashMap<>() + ); + } + + @Override + public void validateRequest(PaymentProviderRequest request) { + MobilepayPaymentRequest mobilePayRequest = (MobilepayPaymentRequest) request; + List violations = new ArrayList<>(); + + // TODO Make sure the phone number is valid (DK, FI, SE or NO) + if (mobilePayRequest.phoneNumber() == null || mobilePayRequest.phoneNumber().isBlank()) { + violations.add("Phone number is required"); + } + + if (mobilePayRequest.returnUrl() == null || mobilePayRequest.returnUrl().isBlank()) { + violations.add("Return URL is required"); + } + + if (!violations.isEmpty()) { + throw new PaymentValidationException("Mobilepay validation: ", violations); + } + } + + @Override + public boolean supportsPaymentMethod(PaymentMethod method) { + return getSupportedPaymentMethods().contains(method); + } + + @Transactional + public void registerWebhook(String callbackUrl) { + mobilePayClient.registerWebhook(callbackUrl); + } + + @Transactional + public void deleteWebhook(String webhookId) { + mobilePayClient.deleteWebhook(webhookId); + } + + @Transactional + public Object getWebhooks() { + return mobilePayClient.getWebhooks(); + } + + @Transactional + public void authenticateWebhook(String date, String contentSha256, String authorization, String payload) { + log.info("Authenticating MobilePay webhook"); + mobilePayClient.validateWebhook(contentSha256, authorization, date, payload); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayPaymentRequest.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayPaymentRequest.java new file mode 100644 index 0000000..0355519 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayPaymentRequest.java @@ -0,0 +1,14 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; + +public record MobilepayPaymentRequest( + PaymentMethod paymentMethod, + String phoneNumber, + String returnUrl +) implements PaymentProviderRequest { + @Override + public PaymentMethod getPaymentMethod() { + return paymentMethod; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayWebhookRegistrationResponse.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayWebhookRegistrationResponse.java new file mode 100644 index 0000000..c52df5e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/MobilepayWebhookRegistrationResponse.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +public record MobilepayWebhookRegistrationResponse( + String secret, + String id +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentId.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentId.java new file mode 100644 index 0000000..c81ef20 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentId.java @@ -0,0 +1,46 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentId { + private String id; + + private PaymentId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static PaymentId generate() { + return new PaymentId(UUID.randomUUID().toString()); + } + + public static PaymentId of(String id) { + return new PaymentId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PaymentId paymentId = (PaymentId) o; + return Objects.equals(id, paymentId.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderConfig.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderConfig.java new file mode 100644 index 0000000..90cec17 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderConfig.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; + +import java.util.Map; + +public record PaymentProviderConfig(PaymentProvider provider, boolean isActive, Map config) { +} + diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderRequest.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderRequest.java new file mode 100644 index 0000000..8422d6d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderRequest.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; + +public interface PaymentProviderRequest { + PaymentMethod getPaymentMethod(); +} + diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderResponse.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderResponse.java new file mode 100644 index 0000000..1f3054b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentProviderResponse.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import java.util.Map; + +public record PaymentProviderResponse( + String providerReference, + String redirectUrl, + Map additionalData +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStateMetadata.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStateMetadata.java new file mode 100644 index 0000000..3d8b06c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStateMetadata.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import java.util.Set; + +/** + * Record to hold state metadata + */ +public record PaymentStateMetadata( + PaymentStatus state, + boolean isTerminal, + Set validTransitions +) { + public boolean canTransitionTo(PaymentStatus targetState) { + return validTransitions.contains(targetState); + } + + public boolean hasTransitions() { + return !validTransitions.isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStatus.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStatus.java new file mode 100644 index 0000000..ca7613b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/PaymentStatus.java @@ -0,0 +1,14 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +public enum PaymentStatus { + PENDING, // Payment has been initiated but not completed + RESERVED, // Payment has been reserved but not captured + CAPTURED, // Payment has been successfully captured + FAILED, // Payment attempt failed + CANCELLED, // Payment was cancelled + REFUNDED, // Payment was fully refunded + PARTIALLY_REFUNDED, // Payment was partially refunded + EXPIRED, // Payment expired before completion + TERMINATED // Payment was terminated by the system +} + diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/TransactionId.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/TransactionId.java new file mode 100644 index 0000000..bcc483c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/TransactionId.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TransactionId { + @Column(name = "transaction_id") + private String id; + + private TransactionId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static TransactionId generate() { + return new TransactionId(UUID.randomUUID().toString()); + } + + public static TransactionId of(String id) { + return new TransactionId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TransactionId paymentId = (TransactionId) o; + return Objects.equals(id, paymentId.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/WebhookRequest.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/WebhookRequest.java new file mode 100644 index 0000000..66aa47c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/WebhookRequest.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.payment.domain.valueobject; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record WebhookRequest( + String body, + Map headers +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundReason.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundReason.java new file mode 100644 index 0000000..3b8cd29 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundReason.java @@ -0,0 +1,28 @@ +package com.zenfulcode.commercify.payment.domain.valueobject.refund; + +import lombok.Getter; + +/** + * Enum for refund reasons + */ +@Getter +public enum RefundReason { + CUSTOMER_REQUEST("Customer requested refund"), + DUPLICATE_PAYMENT("Duplicate payment"), + FRAUDULENT_CHARGE("Fraudulent charge"), + ORDER_CANCELLED("Order cancelled"), + PRODUCT_UNAVAILABLE("Product unavailable"), + SHIPPING_ADDRESS_INVALID("Invalid shipping address"), + PRODUCT_DAMAGED("Product damaged"), + WRONG_ITEM("Wrong item delivered"), + QUALITY_ISSUE("Product quality issue"), + LATE_DELIVERY("Late delivery"), + OTHER("Other reason"); + + private final String description; + + RefundReason(String description) { + this.description = description; + } + +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundRequest.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundRequest.java new file mode 100644 index 0000000..151f3cf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundRequest.java @@ -0,0 +1,94 @@ +package com.zenfulcode.commercify.payment.domain.valueobject.refund; + +import com.zenfulcode.commercify.payment.domain.exception.PaymentValidationException; +import com.zenfulcode.commercify.shared.domain.model.Money; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.Builder; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Embeddable +public class RefundRequest { + @Column(name = "refund_amount") + private Money amount; + + @Enumerated(EnumType.STRING) + @Column(name = "refund_reason") + private RefundReason reason; + + @Column(name = "refund_notes") + private String notes; + + @Column(name = "requested_by") + private String requestedBy; + + @Column(name = "requested_at") + private Instant requestedAt; + + protected RefundRequest() { + // For JPA + } + + @Builder + public RefundRequest( + Money amount, + RefundReason reason, + String notes, + String requestedBy + ) { + validate(amount, reason); + + this.amount = amount; + this.reason = reason; + this.notes = notes; + this.requestedBy = requestedBy; + this.requestedAt = Instant.now(); + } + + private void validate(Money amount, RefundReason reason) { + List violations = new ArrayList<>(); + + if (amount == null) { + violations.add("Refund amount is required"); + } else if (amount.isNegative() || amount.isZero()) { + violations.add("Refund amount must be positive"); + } + + if (reason == null) { + violations.add("Refund reason is required"); + } + + if (!violations.isEmpty()) { + throw new PaymentValidationException("Invalid refund request", violations); + } + } + + public Money amount() { + return amount; + } + + public RefundReason reason() { + return reason; + } + + public String notes() { + return notes; + } + + public String requestedBy() { + return requestedBy; + } + + public Instant requestedAt() { + return requestedAt; + } + + public boolean isFullRefund(Money paymentAmount) { + return amount.equals(paymentAmount); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundStatus.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundStatus.java new file mode 100644 index 0000000..eaebcca --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/refund/RefundStatus.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.payment.domain.valueobject.refund; + +/** + * Enum for refund status + */ +public enum RefundStatus { + PENDING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/MobilepayWebhookPayload.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/MobilepayWebhookPayload.java new file mode 100644 index 0000000..3b79538 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/MobilepayWebhookPayload.java @@ -0,0 +1,41 @@ +package com.zenfulcode.commercify.payment.domain.valueobject.webhook; + +import java.time.Instant; + +public record MobilepayWebhookPayload( + String msn, + String reference, + String pspReference, + String name, + MobilepayAmount amount, + Instant timestamp, + String idempotencyKey, + boolean success +) implements WebhookPayload { + + @Override + public String getEventType() { + return name; + } + + @Override + public String getPaymentReference() { + return reference; + } + + @Override + public Instant getTimestamp() { + return timestamp; + } + + @Override + public boolean isValid() { + return reference != null && name != null; + } + + public record MobilepayAmount( + String currency, + long value + ) { + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/WebhookPayload.java b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/WebhookPayload.java new file mode 100644 index 0000000..917c0ff --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/domain/valueobject/webhook/WebhookPayload.java @@ -0,0 +1,16 @@ +package com.zenfulcode.commercify.payment.domain.valueobject.webhook; + +import java.time.Instant; + +/** + * Base interface for all webhook payloads + */ +public interface WebhookPayload { + String getEventType(); + + String getPaymentReference(); + + Instant getTimestamp(); + + boolean isValid(); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/config/PaymentProviderConfig.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/config/PaymentProviderConfig.java new file mode 100644 index 0000000..dc83fa6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/config/PaymentProviderConfig.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.payment.infrastructure.config; + +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderFactory; +import com.zenfulcode.commercify.payment.domain.service.provider.MobilepayProviderService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class PaymentProviderConfig { + private final PaymentProviderFactory providerFactory; + private final MobilepayProviderService mobilePayService; + + @PostConstruct + public void registerProviders() { + providerFactory.registerProvider(PaymentProvider.MOBILEPAY, mobilePayService); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayCreatePaymentRequest.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayCreatePaymentRequest.java new file mode 100644 index 0000000..402744b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayCreatePaymentRequest.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.payment.infrastructure.gateway; + +import com.zenfulcode.commercify.payment.domain.model.PaymentMethod; +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record MobilepayCreatePaymentRequest( + Money amount, + PaymentMethod paymentMethod, + String phoneNumber, + String returnUrl, + String orderId +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayPaymentResponse.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayPaymentResponse.java new file mode 100644 index 0000000..47a9c45 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayPaymentResponse.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.payment.infrastructure.gateway; + +public record MobilepayPaymentResponse( + String redirectUrl, + String reference +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenResponse.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenResponse.java new file mode 100644 index 0000000..8a0d086 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenResponse.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.payment.infrastructure.gateway; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record MobilepayTokenResponse( + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("expires_in") + String expiresIn, + + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("expires_on") + String expiresOn, + + @JsonProperty("resource") + String resource) { +} diff --git a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayTokenService.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenService.java similarity index 57% rename from src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayTokenService.java rename to src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenService.java index df43af3..def8d75 100644 --- a/src/main/java/com/zenfulcode/commercify/commercify/integration/mobilepay/MobilePayTokenService.java +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/MobilepayTokenService.java @@ -1,10 +1,8 @@ -package com.zenfulcode.commercify.commercify.integration.mobilepay; +package com.zenfulcode.commercify.payment.infrastructure.gateway; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.zenfulcode.commercify.commercify.exception.PaymentProcessingException; -import lombok.RequiredArgsConstructor; +import com.zenfulcode.commercify.payment.domain.exception.PaymentProcessingException; +import com.zenfulcode.commercify.payment.infrastructure.gateway.config.MobilepayConfig; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -14,37 +12,25 @@ import java.util.concurrent.locks.ReentrantLock; @Service -@RequiredArgsConstructor @Slf4j -public class MobilePayTokenService { +public class MobilepayTokenService { private final RestTemplate restTemplate; private final ReentrantLock tokenLock = new ReentrantLock(); - - @Value("${mobilepay.client-id}") - private String clientId; - - @Value("${mobilepay.client-secret}") - private String apiKey; - - @Value("${mobilepay.subscription-key}") - private String subscriptionKey; - - @Value("${mobilepay.merchant-id}") - private String merchantId; - - @Value("${mobilepay.system-name}") - private String systemName; - - @Value("${mobilepay.api-url}") - private String apiUrl; + private final MobilepayConfig config; private String accessToken; private Instant tokenExpiration; + public MobilepayTokenService(RestTemplate restTemplate, MobilepayConfig config) { + this.restTemplate = restTemplate; + this.config = config; + } + public String getAccessToken() { if (shouldRefreshToken()) { refreshAccessToken(); } + return accessToken; } @@ -58,17 +44,17 @@ private void refreshAccessToken() { try { // Double-check after acquiring lock if (shouldRefreshToken()) { - MobilePayTokenResponse tokenResponse = requestNewAccessToken(); + MobilepayTokenResponse tokenResponse = requestNewAccessToken(); accessToken = tokenResponse.accessToken(); - // Parse expires_on timestamp for token expiration + // Parse expires_on timestamp try { long expiresOn = Long.parseLong(tokenResponse.expiresOn()); tokenExpiration = Instant.ofEpochSecond(expiresOn); log.info("Access token refreshed, expires at: {}", tokenExpiration); } catch (NumberFormatException e) { log.error("Failed to parse token expiration timestamp", e); - // Fallback to a conservative expiration time + // Fallback expiration tokenExpiration = Instant.now().plusSeconds(3600); } } @@ -77,19 +63,19 @@ private void refreshAccessToken() { } } - private MobilePayTokenResponse requestNewAccessToken() { + private MobilepayTokenResponse requestNewAccessToken() { try { HttpHeaders headers = createTokenRequestHeaders(); - ResponseEntity response = restTemplate.exchange( - apiUrl + "/accesstoken/get", + ResponseEntity response = restTemplate.exchange( + config.getApiUrl() + "/accesstoken/get", HttpMethod.POST, new HttpEntity<>(headers), - MobilePayTokenResponse.class + MobilepayTokenResponse.class ); if (response.getBody() == null) { - throw new PaymentProcessingException("No response from MobilePay API", null); + throw new PaymentProcessingException("No response from MobilePay token API", null); } return response.getBody(); @@ -102,46 +88,21 @@ private MobilePayTokenResponse requestNewAccessToken() { private HttpHeaders createTokenRequestHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("client_id", clientId); - headers.set("client_secret", apiKey); - headers.set("Ocp-Apim-Subscription-Key", subscriptionKey); - headers.set("Merchant-Serial-Number", merchantId); - headers.set("Vipps-System-Name", systemName); + headers.set("client_id", config.getClientId()); + headers.set("client_secret", config.getClientSecret()); + headers.set("Ocp-Apim-Subscription-Key", config.getSubscriptionKey()); + headers.set("Merchant-Serial-Number", config.getMerchantId()); + headers.set("Vipps-System-Name", config.getSystemName()); headers.set("Vipps-System-Version", "1.0"); headers.set("Vipps-System-Plugin-Name", "commercify"); headers.set("Vipps-System-Plugin-Version", "1.0"); return headers; } - // Refresh token periodically to ensure it's always valid @Scheduled(fixedRate = 3600000) // Every hour public void scheduleTokenRefresh() { if (shouldRefreshToken()) { refreshAccessToken(); } } -} - -record MobilePayTokenResponse( - @JsonProperty("token_type") - String tokenType, - - @JsonProperty("expires_in") - String expiresIn, - - @JsonProperty("ext_expires_in") - String extExpiresIn, - - @JsonProperty("access_token") - String accessToken, - - @JsonProperty("expires_on") - String expiresOn, - - @JsonProperty("not_before") - String notBefore, - - @JsonProperty("resource") - String resource -) { } \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/client/MobilepayClient.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/client/MobilepayClient.java new file mode 100644 index 0000000..8f89fa9 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/client/MobilepayClient.java @@ -0,0 +1,264 @@ +package com.zenfulcode.commercify.payment.infrastructure.gateway.client; + +import com.zenfulcode.commercify.payment.domain.exception.PaymentProcessingException; +import com.zenfulcode.commercify.payment.domain.exception.WebhookProcessingException; +import com.zenfulcode.commercify.payment.domain.exception.WebhookValidationException; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.model.WebhookConfig; +import com.zenfulcode.commercify.payment.domain.repository.WebhookConfigRepository; +import com.zenfulcode.commercify.payment.domain.valueobject.MobilepayWebhookRegistrationResponse; +import com.zenfulcode.commercify.payment.infrastructure.gateway.MobilepayCreatePaymentRequest; +import com.zenfulcode.commercify.payment.infrastructure.gateway.MobilepayPaymentResponse; +import com.zenfulcode.commercify.payment.infrastructure.gateway.MobilepayTokenService; +import com.zenfulcode.commercify.payment.infrastructure.gateway.config.MobilepayConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MobilepayClient { + private final WebhookConfigRepository webhookRepository; + private final RestTemplate restTemplate; + private final MobilepayTokenService tokenService; + + private final MobilepayConfig config; + + @Transactional + public MobilepayPaymentResponse createPayment(MobilepayCreatePaymentRequest request) { + try { + HttpEntity> entity = new HttpEntity<>( + createPaymentRequest(request), + createHeaders() + ); + + ResponseEntity response = restTemplate.exchange( + config.getApiUrl() + "/epayment/v1/payments", + HttpMethod.POST, + entity, + MobilepayPaymentResponse.class + ); + + if (response.getBody() == null) { + throw new PaymentProcessingException("Empty response from MobilePay", null); + } + + return response.getBody(); + } catch (Exception e) { + log.error("Error creating MobilePay payment: {}", e.getMessage()); + throw new PaymentProcessingException("Failed to create MobilePay payment", e); + } + } + + @Transactional(readOnly = true) + public void validateWebhook(String contentSha256, String authorization, String date, String payload) { + try { +// Verify content + log.info("Verifying content"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8)); + String encodedHash = Base64.getEncoder().encodeToString(hash); + + if (!encodedHash.equals(contentSha256)) { + throw new SecurityException("Hash mismatch"); + } + + log.info("Content verified"); + +// Verify signature + log.info("Verifying signature"); + URI uri = new URI(config.getWebhookCallback()); + + String expectedSignedString = String.format("POST\n%s\n%s;%s;%s", uri.getPath(), date, uri.getHost(), encodedHash); + + Mac hmacSha256 = Mac.getInstance("HmacSHA256"); + + byte[] secretByteArray = getWebhookSecret().getBytes(StandardCharsets.UTF_8); + + SecretKeySpec secretKey = new SecretKeySpec(secretByteArray, "HmacSHA256"); + hmacSha256.init(secretKey); + + byte[] hmacSha256Bytes = hmacSha256.doFinal(expectedSignedString.getBytes(StandardCharsets.UTF_8)); + String expectedSignature = Base64.getEncoder().encodeToString(hmacSha256Bytes); + String expectedAuthorization = String.format("HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=%s", expectedSignature); + + if (!authorization.equals(expectedAuthorization)) { + throw new SecurityException("Signature mismatch"); + } + + log.info("Signature verified"); + } catch (NoSuchAlgorithmException e) { + throw new WebhookProcessingException("SHA-256 algorithm not found"); + } catch (InvalidKeyException | URISyntaxException e) { + throw new WebhookProcessingException(e.getMessage()); + } + } + + @Transactional + protected HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + headers.set("Authorization", "Bearer " + tokenService.getAccessToken()); + headers.set("Merchant-Serial-Number", config.getMerchantId()); + headers.set("Ocp-Apim-Subscription-Key", config.getSubscriptionKey()); + headers.set("Vipps-System-Name", config.getSystemName()); + headers.set("Vipps-System-Version", "1.0"); + headers.set("Vipps-System-Plugin-Name", "commercify"); + headers.set("Vipps-System-Plugin-Version", "1.0"); + headers.set("Idempotency-Key", UUID.randomUUID().toString()); + return headers; + } + + // TODO: Improve error handling for invalid currency not including (DKK, NOK, SEK and FIN) + private Map createPaymentRequest(MobilepayCreatePaymentRequest request) { + Map paymentRequest = new HashMap<>(); + + // Amount + final long value = Math.round(request.amount().getAmount().doubleValue() * 100); + + Map amount = new HashMap<>(); + amount.put("value", value); + amount.put("currency", request.amount().getCurrency()); + paymentRequest.put("amount", amount); + + // Payment method + Map paymentMethod = new HashMap<>(); + paymentMethod.put("type", request.paymentMethod().name()); + paymentRequest.put("paymentMethod", paymentMethod); + + // Customer + Map customer = new HashMap<>(); + customer.put("phoneNumber", request.phoneNumber()); + paymentRequest.put("customer", customer); + + // Other fields + paymentRequest.put("reference", "mp-" + request.orderId()); + paymentRequest.put("returnUrl", request.returnUrl() + "?orderId=" + request.orderId()); + paymentRequest.put("userFlow", "WEB_REDIRECT"); + + return paymentRequest; + } + + @Transactional + public void registerWebhook(String callbackUrl) { + HttpHeaders headers = createHeaders(); + + Map request = new HashMap<>(); + request.put("url", callbackUrl); + request.put("events", new String[]{ + "epayments.payment.aborted.v1", + "epayments.payment.expired.v1", + "epayments.payment.cancelled.v1", + "epayments.payment.captured.v1", + "epayments.payment.refunded.v1", + "epayments.payment.authorized.v1" + }); + + HttpEntity> entity = new HttpEntity<>(request, headers); + + try { + ResponseEntity response = restTemplate.exchange( + String.format("%s/webhooks/v1/webhooks", config.getApiUrl()), + HttpMethod.POST, + entity, + MobilepayWebhookRegistrationResponse.class + ); + + if (response.getBody() == null) { + throw new WebhookValidationException("Empty response from MobilePay", null); + } + + // Save or update webhook configuration + saveOrUpdateWebhook(callbackUrl, response.getBody().secret()); + + log.info("Webhook registration response: {}", response.getBody()); + } catch (Exception e) { + log.error("Error registering MobilePay webhook: {}", e.getMessage()); + throw new PaymentProcessingException("Failed to register webhook", e); + } + } + + + @Transactional + public void deleteWebhook(String webhookId) { + HttpHeaders headers = createHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + try { + restTemplate.exchange( + String.format("%s/webhooks/v1/webhooks/%s", config.getApiUrl(), webhookId), + HttpMethod.DELETE, + entity, + Object.class); + + log.info("Webhook deleted successfully: {}", webhookId); + } catch (Exception e) { + log.error("Error deleting MobilePay webhook: {}", e.getMessage()); + throw new WebhookProcessingException("Failed to delete MobilePay webhook"); + } + } + + @Transactional + public Object getWebhooks() { + HttpHeaders headers = createHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange( + String.format("%s/webhooks/v1/webhooks", config.getApiUrl()), + HttpMethod.GET, + entity, + Object.class); + + return response.getBody(); + } catch (Exception e) { + log.error("Error getting MobilePay webhooks: {}", e.getMessage()); + throw new WebhookProcessingException("Failed to get MobilePay webhooks"); + } + } + + protected String getWebhookSecret() { + return webhookRepository.findByProvider(PaymentProvider.MOBILEPAY) + .map(WebhookConfig::getSecret) + .orElseThrow(() -> new PaymentProcessingException("Webhook secret not found", null)); + } + + @Transactional + protected void saveOrUpdateWebhook(String callbackUrl, String secret) { + webhookRepository.findByProvider(PaymentProvider.MOBILEPAY) + .ifPresentOrElse( + config -> { + config.setCallbackUrl(callbackUrl); + config.setSecret(secret); + webhookRepository.save(config); + + log.info("Webhook updated successfully"); + }, + () -> { + WebhookConfig newConfig = WebhookConfig.builder() + .provider(PaymentProvider.MOBILEPAY) + .callbackUrl(callbackUrl) + .secret(secret) + .build(); + webhookRepository.save(newConfig); + + log.info("Webhook registered successfully"); + } + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/config/MobilepayConfig.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/config/MobilepayConfig.java new file mode 100644 index 0000000..a7b133a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/gateway/config/MobilepayConfig.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.payment.infrastructure.gateway.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Setter +@Getter +@Configuration +@ConfigurationProperties(prefix = "integration.payments.mobilepay") +public class MobilepayConfig { + private String clientId; + private String merchantId; + private String clientSecret; + private String subscriptionKey; + private String apiUrl; + private String systemName; + private String webhookCallback; +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/message/events/PaymentEventHandler.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/message/events/PaymentEventHandler.java new file mode 100644 index 0000000..04bdf5a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/message/events/PaymentEventHandler.java @@ -0,0 +1,81 @@ +package com.zenfulcode.commercify.payment.infrastructure.message.events; + +import com.zenfulcode.commercify.order.application.command.CancelOrderCommand; +import com.zenfulcode.commercify.order.application.command.UpdateOrderStatusCommand; +import com.zenfulcode.commercify.order.application.service.OrderApplicationService; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.payment.domain.event.PaymentCancelledEvent; +import com.zenfulcode.commercify.payment.domain.event.PaymentCapturedEvent; +import com.zenfulcode.commercify.payment.domain.event.PaymentFailedEvent; +import com.zenfulcode.commercify.payment.domain.event.PaymentReservedEvent; +import com.zenfulcode.commercify.payment.domain.model.FailureReason; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentEventHandler { + private final OrderApplicationService orderApplicationService; + + @EventListener + @Transactional + public void handlePaymentCancelled(PaymentCancelledEvent event) { + log.info("Handling payment cancelled event for orderId: {}", event.getOrderId()); + + CancelOrderCommand command = new CancelOrderCommand(event.getOrderId()); + + orderApplicationService.cancelOrder(command); + } + + @EventListener + @Transactional + public void handlePaymentCaptured(PaymentCapturedEvent event) { + log.info("Handling payment captured event for orderId: {}", event.getOrderId()); + + UpdateOrderStatusCommand command = new UpdateOrderStatusCommand( + event.getOrderId(), + OrderStatus.COMPLETED + ); + + orderApplicationService.updateOrderStatus(command); + } + + @EventListener + @Transactional + public void handlePaymentReserved(PaymentReservedEvent event) { + log.info("Handling payment reserved event for orderId: {}", event.getOrderId()); + + UpdateOrderStatusCommand command = new UpdateOrderStatusCommand( + event.getOrderId(), + OrderStatus.PAID + ); + + orderApplicationService.updateOrderStatus(command); + } + + @EventListener + @Transactional + public void handlePaymentFailed(PaymentFailedEvent event) { + log.info("Handling payment failed event for orderId: {}", event.getOrderId()); + + UpdateOrderStatusCommand command = new UpdateOrderStatusCommand( + event.getOrderId(), + mapFailureReasonToOrderStatus(event.getFailureReason()) + ); + + orderApplicationService.updateOrderStatus(command); + } + + private OrderStatus mapFailureReasonToOrderStatus(FailureReason failureReason) { + return switch (failureReason) { + case INSUFFICIENT_FUNDS, INVALID_PAYMENT_METHOD, PAYMENT_PROCESSING_ERROR, PAYMENT_METHOD_ERROR, + PAYMENT_PROVIDER_ERROR -> OrderStatus.FAILED; + case PAYMENT_EXPIRED, PAYMENT_TERMINATED -> OrderStatus.ABANDONED; + case UNKNOWN -> null; + }; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaPaymentRepository.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaPaymentRepository.java new file mode 100644 index 0000000..cfe9830 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaPaymentRepository.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.payment.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.repository.PaymentRepository; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JpaPaymentRepository implements PaymentRepository { + private final SpringDataJpaPaymentRepository repository; + + @Override + public Payment save(Payment payment) { + return repository.save(payment); + } + + @Override + public Optional findById(PaymentId id) { + return repository.findById(id); + } + + @Override + public Optional findByProviderReference(String providerReference) { + return repository.findPaymentByProviderReference(providerReference); + } + + @Override + public Optional findByTransactionId(String transactionId) { + return repository.findPaymentByTransactionId(transactionId); + } + + @Override + public Optional findByOrderId(OrderId orderId) { + return repository.findPaymentByOrder_Id(orderId); + } + + @Override + public Page findAll(PageRequest pageRequest) { + return repository.findAll(pageRequest); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaWebhookConfigRepository.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaWebhookConfigRepository.java new file mode 100644 index 0000000..9000ca7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/JpaWebhookConfigRepository.java @@ -0,0 +1,25 @@ +package com.zenfulcode.commercify.payment.infrastructure.persistence; + +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.model.WebhookConfig; +import com.zenfulcode.commercify.payment.domain.repository.WebhookConfigRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JpaWebhookConfigRepository implements WebhookConfigRepository { + private final SpringDataJpaWebhookConfigRepository repository; + + @Override + public WebhookConfig save(WebhookConfig config) { + return repository.save(config); + } + + @Override + public Optional findByProvider(PaymentProvider provider) { + return repository.findWebhookConfigByProvider(provider); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaPaymentRepository.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaPaymentRepository.java new file mode 100644 index 0000000..78df66f --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaPaymentRepository.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.payment.infrastructure.persistence; + +import com.zenfulcode.commercify.order.domain.valueobject.OrderId; +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.valueobject.PaymentId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SpringDataJpaPaymentRepository extends JpaRepository { + Optional findPaymentByProviderReference(String providerReference); + + Optional findPaymentByTransactionId(String transactionId); + + Optional findPaymentByOrder_Id(OrderId orderId); +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaWebhookConfigRepository.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaWebhookConfigRepository.java new file mode 100644 index 0000000..eb835a7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/persistence/SpringDataJpaWebhookConfigRepository.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.payment.infrastructure.persistence; + +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.model.WebhookConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SpringDataJpaWebhookConfigRepository extends JpaRepository { + Optional findWebhookConfigByProvider(PaymentProvider provider); +} diff --git a/src/main/java/com/zenfulcode/commercify/payment/infrastructure/webhook/WebhookHandler.java b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/webhook/WebhookHandler.java new file mode 100644 index 0000000..75a18fd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/payment/infrastructure/webhook/WebhookHandler.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.payment.infrastructure.webhook; + +import com.zenfulcode.commercify.payment.domain.model.Payment; +import com.zenfulcode.commercify.payment.domain.model.PaymentProvider; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderFactory; +import com.zenfulcode.commercify.payment.domain.service.PaymentProviderService; +import com.zenfulcode.commercify.payment.domain.valueobject.webhook.WebhookPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WebhookHandler { + private final PaymentProviderFactory providerFactory; + + public void handleWebhook(PaymentProvider provider, WebhookPayload payload, Payment payment) { + PaymentProviderService service = providerFactory.getProvider(provider); + service.handleCallback(payment, payload); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/ActivateProductCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/ActivateProductCommand.java new file mode 100644 index 0000000..e330b08 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/ActivateProductCommand.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; + +public record ActivateProductCommand( + ProductId productId +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/AddProductVariantsCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/AddProductVariantsCommand.java new file mode 100644 index 0000000..fde319e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/AddProductVariantsCommand.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; + +import java.util.List; + +public record AddProductVariantsCommand( + ProductId productId, + List variantSpecs +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/AdjustInventoryCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/AdjustInventoryCommand.java new file mode 100644 index 0000000..a323ff8 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/AdjustInventoryCommand.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.InventoryAdjustmentType; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; + +public record AdjustInventoryCommand( + ProductId productId, + InventoryAdjustmentType adjustmentType, + int quantity, + String reason +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/CreateProductCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/CreateProductCommand.java new file mode 100644 index 0000000..2fe25ff --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/CreateProductCommand.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record CreateProductCommand( + String name, + String description, + String imageUrl, + int initialStock, + Money price, + List variantSpecs +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/DeactivateProductCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/DeactivateProductCommand.java new file mode 100644 index 0000000..0eadea6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/DeactivateProductCommand.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; + +public record DeactivateProductCommand( + ProductId productId +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/DeleteProductCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/DeleteProductCommand.java new file mode 100644 index 0000000..738dff8 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/DeleteProductCommand.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; + +public record DeleteProductCommand( + ProductId productId +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateProductCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateProductCommand.java new file mode 100644 index 0000000..44fc5b2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateProductCommand.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductUpdateSpec; + +public record UpdateProductCommand( + ProductId productId, + ProductUpdateSpec updateSpec +) {} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateVariantPricesCommand.java b/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateVariantPricesCommand.java new file mode 100644 index 0000000..4f558a7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/command/UpdateVariantPricesCommand.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.product.application.command; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantPriceUpdate; + +import java.util.List; + +public record UpdateVariantPricesCommand( + ProductId productId, + List priceUpdates +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/query/CountNewProductsInPeriodQuery.java b/src/main/java/com/zenfulcode/commercify/product/application/query/CountNewProductsInPeriodQuery.java new file mode 100644 index 0000000..12cffe6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/query/CountNewProductsInPeriodQuery.java @@ -0,0 +1,19 @@ +package com.zenfulcode.commercify.product.application.query; + +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; + +import java.time.LocalDate; + +public record CountNewProductsInPeriodQuery( + String productCategory, + LocalDate startDate, + LocalDate endDate +) { + public static CountNewProductsInPeriodQuery of(MetricsQuery metricsQuery) { + return new CountNewProductsInPeriodQuery( + metricsQuery.productCategory(), + metricsQuery.startDate(), + metricsQuery.endDate() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQuery.java b/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQuery.java new file mode 100644 index 0000000..d7214b5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQuery.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.product.application.query; + + +import com.zenfulcode.commercify.product.domain.valueobject.CategoryId; + +public record ProductQuery( + ProductQueryType type, + CategoryId categoryId, + int threshold +) { + public static ProductQuery all() { + return new ProductQuery(ProductQueryType.ALL, null, 0); + } + + public static ProductQuery active() { + return new ProductQuery(ProductQueryType.ACTIVE, null, 0); + } + + public static ProductQuery byCategory(CategoryId categoryId) { + return new ProductQuery(ProductQueryType.BY_CATEGORY, categoryId, 0); + } + + public static ProductQuery lowStock(int threshold) { + return new ProductQuery(ProductQueryType.LOW_STOCK, null, threshold); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQueryType.java b/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQueryType.java new file mode 100644 index 0000000..b08b3db --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/query/ProductQueryType.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.application.query; + +public enum ProductQueryType { + ALL, + ACTIVE, + BY_CATEGORY, + LOW_STOCK +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/application/service/ProductApplicationService.java b/src/main/java/com/zenfulcode/commercify/product/application/service/ProductApplicationService.java new file mode 100644 index 0000000..f033997 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/application/service/ProductApplicationService.java @@ -0,0 +1,199 @@ +package com.zenfulcode.commercify.product.application.service; + +import com.zenfulcode.commercify.product.application.command.*; +import com.zenfulcode.commercify.product.application.query.CountNewProductsInPeriodQuery; +import com.zenfulcode.commercify.product.application.query.ProductQuery; +import com.zenfulcode.commercify.product.domain.exception.ProductDeletionException; +import com.zenfulcode.commercify.product.domain.exception.ProductNotFoundException; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.repository.ProductRepository; +import com.zenfulcode.commercify.product.domain.service.ProductDomainService; +import com.zenfulcode.commercify.product.domain.valueobject.*; +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductApplicationService { + private final ProductDomainService productDomainService; + private final DomainEventPublisher eventPublisher; + private final ProductRepository productRepository; + + /** + * Creates a new product + */ + @Transactional + public ProductId createProduct(CreateProductCommand command) { + // Convert command to domain specification + ProductSpecification spec = new ProductSpecification( + command.name(), + command.description(), + command.imageUrl(), + command.initialStock(), + command.price(), + command.variantSpecs() + ); + + // Use domain service to create product + Product product = productDomainService.createProduct(spec); + + // Persist the product + Product savedProduct = productRepository.save(product); + + // Publish domain events + eventPublisher.publish(product.getDomainEvents()); + + return savedProduct.getId(); + } + + /** + * Updates an existing product + */ + @Transactional + public void updateProduct(UpdateProductCommand command) { + // Retrieve product + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + // Update product through domain service + productDomainService.updateProduct(product, command.updateSpec()); + + // Save changes + productRepository.save(product); + + // Publish events + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Handles product inventory adjustments + */ + @Transactional + public void adjustInventory(AdjustInventoryCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + InventoryAdjustment adjustment = new InventoryAdjustment( + command.adjustmentType(), + command.quantity(), + command.reason() + ); + + productDomainService.adjustInventory(product, adjustment); + productRepository.save(product); + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Updates variant prices + */ + @Transactional + public void updateVariantPrices(UpdateVariantPricesCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + productDomainService.updateVariantPrices(product, command.priceUpdates()); + productRepository.save(product); + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Adds variants to a product + */ + @Transactional + public void addProductVariants(AddProductVariantsCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + productDomainService.createProductVariants(product, command.variantSpecs()); + productRepository.save(product); + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Deactivates a product + */ + @Transactional + public void deactivateProduct(DeactivateProductCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + product.deactivate(); + productRepository.save(product); + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Activates a product + */ + @Transactional + public void activateProduct(ActivateProductCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + product.activate(); + productRepository.save(product); + eventPublisher.publish(product.getDomainEvents()); + } + + /** + * Deletes a product + */ + @Transactional + public void deleteProduct(DeleteProductCommand command) { + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new ProductNotFoundException(command.productId())); + + ProductDeletionValidation validation = productDomainService.validateProductDeletion(product); + + if (!validation.canDelete()) { + throw new ProductDeletionException( + "Cannot delete product", + validation.issues() + ); + } + + productRepository.delete(product); + eventPublisher.publish(product.getDomainEvents()); + } + + @Transactional(readOnly = true) + public List findAllProducts(Collection productIds) { + return productDomainService.getAllProductsById(productIds); + } + + /** + * Queries for products + */ + @Transactional(readOnly = true) + public Page findProducts(ProductQuery query, Pageable pageable) { + return switch (query.type()) { + case ALL -> productRepository.findAll(pageable); + case ACTIVE -> productRepository.findByActiveTrue(pageable); + case BY_CATEGORY -> productRepository.findByCategory(query.categoryId(), pageable); + case LOW_STOCK -> productRepository.findByStockLessThan(query.threshold(), pageable); + }; + } + + @Transactional(readOnly = true) + public Product getProductById(ProductId productId) { + return productDomainService.getProductById(productId); + } + + @Transactional(readOnly = true) + public List findVariantsByIds(List variantIds) { + return productRepository.findVariantsByIds(variantIds); + } + + public int countNewProductsInPeriod(CountNewProductsInPeriodQuery query) { + return productDomainService.countNewProductsInPeriod(query.startDate(), query.endDate()); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/event/LargeStockIncreaseEvent.java b/src/main/java/com/zenfulcode/commercify/product/domain/event/LargeStockIncreaseEvent.java new file mode 100644 index 0000000..3ef868d --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/event/LargeStockIncreaseEvent.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.product.domain.event; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +@Getter +public class LargeStockIncreaseEvent extends DomainEvent { + @AggregateId + private final ProductId productId; + private final int quantity; + private final String reason; + + public LargeStockIncreaseEvent(Object source, ProductId productId, int quantity, String reason) { + super(source); + this.productId = productId; + this.quantity = quantity; + this.reason = reason; + } + + @Override + public String getEventType() { + return "LARGE_STOCK_INCREASE"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/event/LowStockEvent.java b/src/main/java/com/zenfulcode/commercify/product/domain/event/LowStockEvent.java new file mode 100644 index 0000000..9640a27 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/event/LowStockEvent.java @@ -0,0 +1,24 @@ +package com.zenfulcode.commercify.product.domain.event; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +@Getter +public class LowStockEvent extends DomainEvent { + @AggregateId + private final ProductId productId; + private final int stockAmount; + + public LowStockEvent(Object source, ProductId productId, int stockAmount) { + super(source); + this.productId = productId; + this.stockAmount = stockAmount; + } + + @Override + public String getEventType() { + return "LOW_STOCK"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductCreatedEvent.java b/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductCreatedEvent.java new file mode 100644 index 0000000..f93ae35 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductCreatedEvent.java @@ -0,0 +1,27 @@ +package com.zenfulcode.commercify.product.domain.event; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +@Getter +public class ProductCreatedEvent extends DomainEvent { + @AggregateId + private final ProductId productId; + private final String name; + private final Money price; + + public ProductCreatedEvent(Object source, ProductId productId, String name, Money price) { + super(source); + this.productId = productId; + this.name = name; + this.price = price; + } + + @Override + public String getEventType() { + return "PRODUCT_CREATED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductPriceUpdatedEvent.java b/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductPriceUpdatedEvent.java new file mode 100644 index 0000000..9abb48f --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/event/ProductPriceUpdatedEvent.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.product.domain.event; + + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.Money; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +@Getter +public class ProductPriceUpdatedEvent extends DomainEvent { + @AggregateId + private final ProductId productId; + private final Money newPrice; + + public ProductPriceUpdatedEvent(Object source, ProductId productId, Money newPrice) { + super(source); + this.productId = productId; + this.newPrice = newPrice; + } + + @Override + public String getEventType() { + return "PRODUCT_PRICE_UPDATED"; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/event/StockCorrectionEvent.java b/src/main/java/com/zenfulcode/commercify/product/domain/event/StockCorrectionEvent.java new file mode 100644 index 0000000..cd895af --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/event/StockCorrectionEvent.java @@ -0,0 +1,26 @@ +package com.zenfulcode.commercify.product.domain.event; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import lombok.Getter; + +@Getter +public class StockCorrectionEvent extends DomainEvent { + @AggregateId + private final ProductId productId; + private final int quantity; + private final String reason; + + public StockCorrectionEvent(Object source, ProductId productId, int quantity, String reason) { + super(source); + this.productId = productId; + this.quantity = quantity; + this.reason = reason; + } + + @Override + public String getEventType() { + return "STOCK_CORRECTION"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/InsufficientStockException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/InsufficientStockException.java new file mode 100644 index 0000000..cc29a44 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/InsufficientStockException.java @@ -0,0 +1,20 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import lombok.Getter; + +@Getter +public class InsufficientStockException extends DomainException { + private final ProductId productId; + private final int requestedQuantity; + private final int availableStock; + + public InsufficientStockException(ProductId productId, int requestedQuantity, int availableStock) { + super(String.format("Insufficient stock for product %s. Requested: %d, Available: %d", + productId, requestedQuantity, availableStock)); + this.productId = productId; + this.requestedQuantity = requestedQuantity; + this.availableStock = availableStock; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/InvalidPriceException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/InvalidPriceException.java new file mode 100644 index 0000000..0f2cbc0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/InvalidPriceException.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class InvalidPriceException extends DomainException { + public InvalidPriceException(String message) { + super(message); + } + + public InvalidPriceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductDeletionException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductDeletionException.java new file mode 100644 index 0000000..872b563 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductDeletionException.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +public class ProductDeletionException extends DomainValidationException { + public ProductDeletionException(String message, List violations) { + super(message, violations); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductModificationException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductModificationException.java new file mode 100644 index 0000000..9ebd91b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductModificationException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class ProductModificationException extends DomainException { + public ProductModificationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductNotFoundException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductNotFoundException.java new file mode 100644 index 0000000..2cdbb9b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductNotFoundException.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.exception.EntityNotFoundException; + +public class ProductNotFoundException extends EntityNotFoundException { + public ProductNotFoundException(ProductId productId) { + super("Product", productId); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductValidationException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductValidationException.java new file mode 100644 index 0000000..4092a96 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/ProductValidationException.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +public class ProductValidationException extends DomainValidationException { + public ProductValidationException(String message) { + super(message, List.of(message)); + } + + public ProductValidationException(List violations) { + super("Product validation failed: " + String.join(", ", violations), violations); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/exception/VariantNotFoundException.java b/src/main/java/com/zenfulcode/commercify/product/domain/exception/VariantNotFoundException.java new file mode 100644 index 0000000..8b29ba1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/exception/VariantNotFoundException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.product.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.EntityNotFoundException; + +public class VariantNotFoundException extends EntityNotFoundException { + public VariantNotFoundException(Object entityId) { + super("Product variant", entityId); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/model/Product.java b/src/main/java/com/zenfulcode/commercify/product/domain/model/Product.java new file mode 100644 index 0000000..08240e2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/model/Product.java @@ -0,0 +1,191 @@ +package com.zenfulcode.commercify.product.domain.model; + +import com.zenfulcode.commercify.product.domain.event.ProductCreatedEvent; +import com.zenfulcode.commercify.product.domain.event.ProductPriceUpdatedEvent; +import com.zenfulcode.commercify.product.domain.exception.InsufficientStockException; +import com.zenfulcode.commercify.product.domain.exception.ProductModificationException; +import com.zenfulcode.commercify.product.domain.valueobject.CategoryId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.shared.domain.model.AggregateRoot; +import com.zenfulcode.commercify.shared.domain.model.Money; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.lang3.NotImplementedException; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "products") +public class Product extends AggregateRoot { + @EmbeddedId + private ProductId id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "active", nullable = false) + private Boolean active = false; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "unit_price")), + @AttributeOverride(name = "currency", column = @Column(name = "currency")) + }) + private Money price; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "category_id")) + private CategoryId categoryId; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "product") + private Set productVariants = new LinkedHashSet<>(); + + @CreationTimestamp + @Column(name = "created_at") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + public static Product create(String name, String description, String imageUrl, int stock, Money money) { + Product product = new Product(); + product.id = ProductId.generate(); + product.name = Objects.requireNonNull(name, "Product name is required"); + product.description = description; + product.imageUrl = imageUrl; + product.stock = stock; + product.price = Objects.requireNonNull(money, "Product price is required"); + product.active = true; + + // Register domain event + product.registerEvent(new ProductCreatedEvent( + product, + product.getId(), + product.getName(), + product.getPrice() + )); + + return product; + } + + // Domain methods + public void updateStock(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("Stock cannot be negative"); + } + this.stock = quantity; + } + + public void addStock(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + this.stock += quantity; + } + + public void removeStock(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + if (quantity > this.stock) { + throw new InsufficientStockException(this.id, quantity, this.stock); + } + this.stock -= quantity; + } + + public void updatePrice(Money newPrice) { + this.price = Objects.requireNonNull(newPrice, "Price cannot be null"); + + registerEvent(new ProductPriceUpdatedEvent( + this, + id, + newPrice + )); + } + + public void activate() { + this.active = true; + } + + public void deactivate() { + this.active = false; + } + + public boolean hasEnoughStock(int quantity) { + return this.stock >= quantity; + } + + public void addVariant(ProductVariant variant) { + Objects.requireNonNull(variant, "Variant cannot be null"); + productVariants.add(variant); + variant.setProduct(this); + } + + public void removeVariant(ProductVariant variant) { + if (variant.hasActiveOrders()) { + throw new ProductModificationException("Cannot remove variant with active orders"); + } + productVariants.remove(variant); + variant.setProduct(null); + } + + public boolean hasVariant(String sku) { + return productVariants.stream() + .anyMatch(variant -> variant.getSku().equals(sku)); + } + + public Money getEffectivePrice(ProductVariant variant) { + return variant != null && variant.getPrice() != null ? + variant.getPrice() : this.price; + } + + public void setName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Product name cannot be empty"); + } + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Product product)) return false; + return Objects.equals(id, product.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public Optional findVariantBySku(String sku) { + throw new NotImplementedException("find variant by sku has not been implemented"); + } + + public boolean isActive() { + return active; + } + + public boolean hasVariants() { + return !productVariants.isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/model/ProductVariant.java b/src/main/java/com/zenfulcode/commercify/product/domain/model/ProductVariant.java new file mode 100644 index 0000000..6214dcc --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/model/ProductVariant.java @@ -0,0 +1,108 @@ +package com.zenfulcode.commercify.product.domain.model; + +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import com.zenfulcode.commercify.shared.domain.model.Money; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "product_variants", uniqueConstraints = { + @UniqueConstraint(name = "uc_product_variants_sku", columnNames = {"sku"}) +}) +public class ProductVariant { + @EmbeddedId + private VariantId id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(name = "sku", nullable = false) + private String sku; + + @Column(name = "stock") + private Integer stock; + + @Column(name = "image_url") + private String imageUrl; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "amount", column = @Column(name = "unit_price")), + @AttributeOverride(name = "currency", column = @Column(name = "currency")) + }) + private Money price; + + @OneToMany(mappedBy = "productVariant", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private Set variantOptions = new LinkedHashSet<>(); + + public static ProductVariant create(String sku, Integer stock, Money price, String imageUrl) { + ProductVariant variant = new ProductVariant(); + variant.id = VariantId.generate(); + variant.sku = Objects.requireNonNull(sku, "SKU is required"); + variant.stock = stock; + variant.price = price; + variant.imageUrl = imageUrl; + return variant; + } + + // Domain methods + public void updateStock(Integer stock) { + if (stock != null && stock < 0) { + throw new IllegalArgumentException("Stock cannot be negative"); + } + this.stock = stock; + } + + public void addOption(String name, String value) { + variantOptions.add(VariantOption.create(name, value, this)); + } + + public boolean hasActiveOrders() { + // TODO: This would typically check a repository or domain service + return false; + } + + public boolean belongsTo(Product product) { + return this.product.getId().equals(product.getId()); + } + + public boolean hasEnoughStock(int requestedQuantity) { + if (stock == null) { + // If variant doesn't manage its own stock, delegate to product + return product.hasEnoughStock(requestedQuantity); + } + return stock >= requestedQuantity; + } + + public int getEffectiveStock() { + return stock != null ? stock : product.getStock(); + } + + public Money getEffectivePrice() { + return price != null ? price : product.getPrice(); + } + + public void updatePrice(Money newPrice) { + this.price = Objects.requireNonNull(newPrice, "Price cannot be null"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ProductVariant that)) return false; + return Objects.equals(sku, that.sku); + } + + @Override + public int hashCode() { + return Objects.hash(sku); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/model/VariantOption.java b/src/main/java/com/zenfulcode/commercify/product/domain/model/VariantOption.java new file mode 100644 index 0000000..d0501ed --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/model/VariantOption.java @@ -0,0 +1,50 @@ +package com.zenfulcode.commercify.product.domain.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.Objects; + +@Getter +@Setter +@Entity +@Table(name = "variant_options") +public class VariantOption { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "value", nullable = false) + private String value; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_variant_id", nullable = false) + private ProductVariant productVariant; + + public static VariantOption create(String name, String value, ProductVariant variant) { + VariantOption option = new VariantOption(); + option.name = Objects.requireNonNull(name, "Option name is required"); + option.value = Objects.requireNonNull(value, "Option value is required"); + option.productVariant = Objects.requireNonNull(variant, "Variant is required"); + return option; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof VariantOption that)) return false; + return Objects.equals(name, that.name) && + Objects.equals(value, that.value) && + Objects.equals(productVariant, that.productVariant); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductRepository.java b/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductRepository.java new file mode 100644 index 0000000..6208dd6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductRepository.java @@ -0,0 +1,36 @@ +package com.zenfulcode.commercify.product.domain.repository; + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.CategoryId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + + Optional findById(ProductId id); + + void delete(Product product); + + Page findAll(Pageable pageable); + + Page findByActiveTrue(Pageable pageable); + + Page findByCategory(CategoryId categoryId, Pageable pageable); + + Page findByStockLessThan(int threshold, Pageable pageable); + + List findAllById(Collection ids); + + List findVariantsByIds(Collection variantIds); + + int findNewProducts(Instant startDate, Instant endDate); +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductVariantRepository.java b/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductVariantRepository.java new file mode 100644 index 0000000..e2a4f77 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/repository/ProductVariantRepository.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.product.domain.repository; + +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ProductVariantRepository { + ProductVariant save(ProductVariant variant); + Optional findById(Long id); + Optional findBySku(String sku); + void delete(ProductVariant variant); + List findByProductId(ProductId productId); +} + diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductInventoryPolicy.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductInventoryPolicy.java new file mode 100644 index 0000000..b6b3ae6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductInventoryPolicy.java @@ -0,0 +1,55 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.event.LargeStockIncreaseEvent; +import com.zenfulcode.commercify.product.domain.event.LowStockEvent; +import com.zenfulcode.commercify.product.domain.event.StockCorrectionEvent; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.valueobject.InventoryAdjustment; +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DefaultProductInventoryPolicy implements ProductInventoryPolicy { + private static final int LOW_STOCK_THRESHOLD = 5; + private static final int REORDER_THRESHOLD = 10; + + private final DomainEventPublisher eventPublisher; + + @Override + public void initializeInventory(Product product) { + if (product.getStock() <= REORDER_THRESHOLD) { + eventPublisher.publish(new LowStockEvent(this, product.getId(), product.getStock())); + } + } + + @Override + public void handleStockIncrease(Product product, InventoryAdjustment adjustment) { + if (adjustment.quantity() > 100) { + eventPublisher.publish(new LargeStockIncreaseEvent( + this, + product.getId(), + adjustment.quantity(), + adjustment.reason() + )); + } + } + + @Override + public void handleStockDecrease(Product product, InventoryAdjustment adjustment) { + if (product.getStock() <= LOW_STOCK_THRESHOLD) { + eventPublisher.publish(new LowStockEvent(this, product.getId(), product.getStock())); + } + } + + @Override + public void handleStockCorrection(Product product, InventoryAdjustment adjustment) { + eventPublisher.publish(new StockCorrectionEvent( + this, + product.getId(), + adjustment.quantity(), + adjustment.reason() + )); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductPricingPolicy.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductPricingPolicy.java new file mode 100644 index 0000000..d243499 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/DefaultProductPricingPolicy.java @@ -0,0 +1,59 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.exception.InvalidPriceException; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Service +@RequiredArgsConstructor +public class DefaultProductPricingPolicy implements ProductPricingPolicy { + private static final BigDecimal MAXIMUM_VARIANT_PRICE_MULTIPLIER = new BigDecimal("3.0"); + private static final BigDecimal MINIMUM_MARGIN_PERCENTAGE = new BigDecimal("0.10"); + + @Override + public void applyDefaultPricing(Product product) { + // Apply minimum margin check + if (product.getPrice().isLessThan(calculateMinimumPrice(product))) { + throw new InvalidPriceException("Price does not meet minimum margin requirements"); + } + } + + @Override + public Money calculateVariantPrice(Product product, VariantSpecification spec) { + if (spec.price() == null) { + return product.getPrice(); + } + + // Validate variant price is not too high compared to base price + if (spec.price().getAmount().divide(product.getPrice().getAmount(), 2, RoundingMode.HALF_UP) + .compareTo(MAXIMUM_VARIANT_PRICE_MULTIPLIER) > 0) { + throw new InvalidPriceException("Variant price cannot exceed 3x the base product price"); + } + + return spec.price(); + } + + + // TODO: Rework this method + @Override + public Money validateAndAdjustPrice(Product product, ProductVariant variant, Money newPrice) { + // Ensure variant price meets minimum margin + if (newPrice.isLessThan(calculateMinimumPrice(product))) { + throw new InvalidPriceException("Variant price does not meet minimum margin requirements"); + } + + return newPrice; + } + + // TODO: Rework this method + private Money calculateMinimumPrice(Product product) { + return product.getPrice().multiply(MINIMUM_MARGIN_PERCENTAGE); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductDomainService.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductDomainService.java new file mode 100644 index 0000000..653281c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductDomainService.java @@ -0,0 +1,225 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.order.domain.repository.OrderLineRepository; +import com.zenfulcode.commercify.product.domain.exception.InvalidPriceException; +import com.zenfulcode.commercify.product.domain.exception.ProductNotFoundException; +import com.zenfulcode.commercify.product.domain.exception.ProductValidationException; +import com.zenfulcode.commercify.product.domain.exception.VariantNotFoundException; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.repository.ProductRepository; +import com.zenfulcode.commercify.product.domain.valueobject.*; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class ProductDomainService { + private final OrderLineRepository orderLineRepository; + private final ProductRepository productRepository; + private final ProductInventoryPolicy inventoryPolicy; + private final ProductPricingPolicy pricingPolicy; + private final ProductFactory productFactory; + + /** + * Creates a new product with validation and enrichment + */ + public Product createProduct(ProductSpecification spec) { + return productFactory.createProduct(spec); + } + + /** + * Handles complex variant creation logic + */ + public void createProductVariants(Product product, List variantSpecs) { + productFactory.createVariants(product, variantSpecs); + } + + /** + * Validates if a product can be deleted + */ + public ProductDeletionValidation validateProductDeletion(Product product) { + List issues = new ArrayList<>(); + + // Check for active orders + if (orderLineRepository.hasActiveOrders(product.getId())) { + issues.add("Product has active orders"); + } + + // Check variants + for (ProductVariant variant : product.getProductVariants()) { + if (orderLineRepository.hasActiveOrdersForVariant(variant.getId())) { + issues.add("Variant " + variant.getSku() + " has active orders"); + } + } + + // Check inventory + if (product.getStock() > 0) { + issues.add("Product has remaining stock of " + product.getStock() + " units"); + } + + return new ProductDeletionValidation(issues.isEmpty(), issues); + } + + /** + * Updates product stock based on complex business rules + */ + public void adjustInventory(Product product, InventoryAdjustment adjustment) { + validateInventoryAdjustment(adjustment); + + switch (adjustment.type()) { + case STOCK_ADDITION -> { + product.addStock(adjustment.quantity()); + inventoryPolicy.handleStockIncrease(product, adjustment); + } + case STOCK_REMOVAL -> { + product.removeStock(adjustment.quantity()); + inventoryPolicy.handleStockDecrease(product, adjustment); + } + case STOCK_CORRECTION -> { + product.updateStock(adjustment.quantity()); + inventoryPolicy.handleStockCorrection(product, adjustment); + } + } + } + + /** + * Handles variant pricing updates with validation + */ + public void updateVariantPrices(Product product, List updates) { + validatePriceUpdates(updates); + + for (VariantPriceUpdate update : updates) { + ProductVariant variant = product.findVariantBySku(update.sku()) + .orElseThrow(() -> new VariantNotFoundException(update.sku())); + + Money newPrice = pricingPolicy.validateAndAdjustPrice( + product, + variant, + update.newPrice() + ); + + variant.updatePrice(newPrice); + } + } + + private void validateInventoryAdjustment(InventoryAdjustment adjustment) { + if (adjustment.quantity() < 0) { + throw new IllegalArgumentException("Adjustment quantity cannot be negative"); + } + } + + private void validatePriceUpdates(List updates) { + updates.forEach(update -> { + if (update.newPrice().isNegative()) { + throw new IllegalArgumentException( + "Price cannot be negative for variant: " + update.sku() + ); + } + }); + } + + public void updateProduct(Product product, ProductUpdateSpec updateSpec) { + // Validate the product is not null + Objects.requireNonNull(product, "Product cannot be null"); + Objects.requireNonNull(updateSpec, "Update specification cannot be null"); + + List violations = new ArrayList<>(); + + // Update name if specified + if (updateSpec.hasNameUpdate()) { + if (updateSpec.name() == null || updateSpec.name().isBlank()) { + violations.add("Product name cannot be empty"); + } else { + product.setName(updateSpec.name()); + } + } + + // Update description if specified + if (updateSpec.hasDescriptionUpdate()) { + product.setDescription(updateSpec.description()); + } + + // Update stock if specified + if (updateSpec.hasStockUpdate()) { + if (updateSpec.stock() < 0) { + violations.add("Stock cannot be negative"); + } else { + InventoryAdjustment adjustment = new InventoryAdjustment( + InventoryAdjustmentType.STOCK_CORRECTION, + updateSpec.stock(), + "Stock updated through product update" + ); + this.adjustInventory(product, adjustment); + } + } + + // Update price if specified + if (updateSpec.hasPriceUpdate()) { + if (updateSpec.price() == null || updateSpec.price().isNegative()) { + violations.add("Price must be non-negative"); + } else { + try { + pricingPolicy.applyDefaultPricing(product); + product.updatePrice(updateSpec.price()); + } catch (InvalidPriceException e) { + violations.add(e.getMessage()); + } + } + } + + // Update active status if specified + if (updateSpec.hasActiveUpdate()) { + if (updateSpec.active()) { + product.activate(); + } else { + product.deactivate(); + } + } + + // If there are any violations, throw an exception + if (!violations.isEmpty()) { + throw new ProductValidationException(violations); + } + + if (updateSpec.hasStockUpdate()) { + inventoryPolicy.initializeInventory(product); + } + } + + public Product getProductById(ProductId productId) { + return productRepository.findById(productId).orElseThrow( + () -> new ProductNotFoundException(productId) + ); + } + + public List getAllProductsById(Collection productIds) { + List products = productRepository.findAllById(productIds); + + if (products.size() != productIds.size()) { + for (ProductId prodId : productIds) { + if (products.stream().noneMatch(p -> p.getId().equals(prodId))) { + throw new ProductNotFoundException(prodId); + } + } + } + + return products; + } + + public int countNewProductsInPeriod(LocalDate startDate, LocalDate endDate) { + Instant start = startDate.atStartOfDay().toInstant(ZoneOffset.from(ZoneOffset.UTC)); + Instant end = endDate.atTime(23, 59).toInstant(ZoneOffset.UTC); + + return productRepository.findNewProducts(start, end); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductFactory.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductFactory.java new file mode 100644 index 0000000..a184d41 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductFactory.java @@ -0,0 +1,91 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.exception.ProductValidationException; +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.ProductSpecification; +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; +import com.zenfulcode.commercify.shared.domain.model.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductFactory { + private final SkuGenerator skuGenerator; + private final DefaultProductPricingPolicy pricingPolicy; + private final DefaultProductInventoryPolicy inventoryPolicy; + + public Product createProduct(ProductSpecification spec) { + // Validate specification + validateSpecification(spec); + + // Create base product + Product product = Product.create(spec.name(), spec.description(), spec.imageUrl(), spec.initialStock(), spec.price()); + + // Apply default policies + pricingPolicy.applyDefaultPricing(product); + inventoryPolicy.initializeInventory(product); + + // Create variants if specified + if (spec.hasVariants()) { + createVariants(product, spec.variantSpecs()); + } + + return product; + } + + public void createVariants(Product product, List specs) { + for (VariantSpecification spec : specs) { + validateVariantSpecification(spec); + + String sku = skuGenerator.generateSku(product, spec); + Money variantPrice = pricingPolicy.calculateVariantPrice(product, spec); + + ProductVariant variant = ProductVariant.create(sku, spec.stock(), variantPrice, spec.imageUrl()); + + // Add variant options + spec.options().forEach(option -> + variant.addOption(option.name(), option.value()) + ); + + product.addVariant(variant); + } + } + + private void validateVariantSpecification(VariantSpecification spec) { + List violations = new ArrayList<>(); + + if (spec.options() == null || spec.options().isEmpty()) { + violations.add("Variant must have at least one option"); + } + if (spec.stock() != null && spec.stock() < 0) { + violations.add("Variant stock cannot be negative"); + } + + if (!violations.isEmpty()) { + throw new ProductValidationException(violations); + } + } + + private void validateSpecification(ProductSpecification spec) { + List violations = new ArrayList<>(); + + if (spec.name() == null || spec.name().isBlank()) { + violations.add("Product name is required"); + } + if (spec.price() == null || spec.price().isNegative()) { + violations.add("Valid product price is required"); + } + if (spec.initialStock() < 0) { + violations.add("Initial stock cannot be negative"); + } + + if (!violations.isEmpty()) { + throw new ProductValidationException(violations); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductInventoryPolicy.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductInventoryPolicy.java new file mode 100644 index 0000000..ef8dbba --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductInventoryPolicy.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.valueobject.InventoryAdjustment; + +public interface ProductInventoryPolicy { + void initializeInventory(Product product); + void handleStockIncrease(Product product, InventoryAdjustment adjustment); + void handleStockDecrease(Product product, InventoryAdjustment adjustment); + void handleStockCorrection(Product product, InventoryAdjustment adjustment); +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductPricingPolicy.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductPricingPolicy.java new file mode 100644 index 0000000..fd34c74 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/ProductPricingPolicy.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; +import com.zenfulcode.commercify.shared.domain.model.Money; + +public interface ProductPricingPolicy { + void applyDefaultPricing(Product product); + Money calculateVariantPrice(Product product, VariantSpecification spec); + Money validateAndAdjustPrice(Product product, ProductVariant variant, Money newPrice); +} + diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/service/SkuGenerator.java b/src/main/java/com/zenfulcode/commercify/product/domain/service/SkuGenerator.java new file mode 100644 index 0000000..99ff59b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/service/SkuGenerator.java @@ -0,0 +1,29 @@ +package com.zenfulcode.commercify.product.domain.service; + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.valueobject.VariantSpecification; +import org.springframework.stereotype.Service; + +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class SkuGenerator { + private static final int SKU_LENGTH = 8; + + public String generateSku(Product product, VariantSpecification spec) { + String basePrefix = product.getName() + .substring(0, Math.min(3, product.getName().length())) + .toUpperCase(); + + String variantSuffix = spec.options().stream() + .map(opt -> opt.value().substring(0, 1)) + .collect(Collectors.joining()); + + String uniquePart = UUID.randomUUID() + .toString() + .substring(0, SKU_LENGTH - basePrefix.length() - variantSuffix.length()); + + return basePrefix + uniquePart + variantSuffix; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/CategoryId.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/CategoryId.java new file mode 100644 index 0000000..b138e39 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/CategoryId.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CategoryId { + @Column(name = "id") + private String id; + + private CategoryId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static CategoryId generate() { + return new CategoryId(UUID.randomUUID().toString()); + } + + public static CategoryId of(String id) { + return new CategoryId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CategoryId that = (CategoryId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustment.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustment.java new file mode 100644 index 0000000..a17a906 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustment.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + + +public record InventoryAdjustment( + InventoryAdjustmentType type, + int quantity, + String reason +) {} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustmentType.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustmentType.java new file mode 100644 index 0000000..daaf377 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/InventoryAdjustmentType.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +public enum InventoryAdjustmentType { + STOCK_ADDITION, + STOCK_REMOVAL, + STOCK_CORRECTION +} diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductDeletionValidation.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductDeletionValidation.java new file mode 100644 index 0000000..7a5b77c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductDeletionValidation.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import java.util.List; + +public record ProductDeletionValidation( + boolean canDelete, + List issues +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductId.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductId.java new file mode 100644 index 0000000..5b3badd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductId.java @@ -0,0 +1,46 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductId { + private String id; + + private ProductId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static ProductId generate() { + return new ProductId(UUID.randomUUID().toString()); + } + + public static ProductId of(String id) { + return new ProductId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProductId that = (ProductId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductSpecification.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductSpecification.java new file mode 100644 index 0000000..4aa9f84 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductSpecification.java @@ -0,0 +1,18 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record ProductSpecification( + String name, + String description, + String imageUrl, + int initialStock, + Money price, + List variantSpecs +) { + public boolean hasVariants() { + return variantSpecs != null && !variantSpecs.isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductUpdateSpec.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductUpdateSpec.java new file mode 100644 index 0000000..a5bc011 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/ProductUpdateSpec.java @@ -0,0 +1,31 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record ProductUpdateSpec( + String name, + String description, + Integer stock, + Money price, + Boolean active +) { + public boolean hasNameUpdate() { + return name != null; + } + + public boolean hasDescriptionUpdate() { + return description != null; + } + + public boolean hasStockUpdate() { + return stock != null; + } + + public boolean hasPriceUpdate() { + return price != null; + } + + public boolean hasActiveUpdate() { + return active != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantId.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantId.java new file mode 100644 index 0000000..e67c0d0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantId.java @@ -0,0 +1,47 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VariantId extends ProductId { + private String id; + + private VariantId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static VariantId generate() { + return new VariantId(UUID.randomUUID().toString()); + } + + public static VariantId of(String id) { + if (id == null) return null; + return new VariantId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + VariantId that = (VariantId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantOption.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantOption.java new file mode 100644 index 0000000..9cdfc8f --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantOption.java @@ -0,0 +1,6 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +public record VariantOption( + String name, + String value +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantPriceUpdate.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantPriceUpdate.java new file mode 100644 index 0000000..83e13b0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantPriceUpdate.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +public record VariantPriceUpdate( + String sku, + Money newPrice +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantSpecification.java b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantSpecification.java new file mode 100644 index 0000000..b9d15bf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/domain/valueobject/VariantSpecification.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.product.domain.valueobject; + +import com.zenfulcode.commercify.shared.domain.model.Money; + +import java.util.List; + +public record VariantSpecification( + Integer stock, + Money price, + String imageUrl, + List options +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/JpaProductRepository.java b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/JpaProductRepository.java new file mode 100644 index 0000000..6a70c86 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/JpaProductRepository.java @@ -0,0 +1,78 @@ +package com.zenfulcode.commercify.product.infrastructure.persistence; + + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.repository.ProductRepository; +import com.zenfulcode.commercify.product.domain.valueobject.CategoryId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public class JpaProductRepository implements ProductRepository { + private final SpringDataJpaProductRepository repository; + private final SpringDataJpaVariantRepository variantRepository; + + JpaProductRepository(SpringDataJpaProductRepository repository, SpringDataJpaVariantRepository variantRepository) { + this.repository = repository; + this.variantRepository = variantRepository; + } + + @Override + public Product save(Product product) { + return repository.save(product); + } + + @Override + public Optional findById(ProductId id) { + return repository.findById(id); + } + + @Override + public void delete(Product product) { + repository.delete(product); + } + + @Override + public Page findAll(Pageable pageable) { + return repository.findAll(pageable); + } + + @Override + public Page findByActiveTrue(Pageable pageable) { + return repository.findByActiveTrue(pageable); + } + + @Override + public Page findByCategory(CategoryId categoryId, Pageable pageable) { + return repository.findByCategoryId(categoryId, pageable); + } + + @Override + public Page findByStockLessThan(int threshold, Pageable pageable) { + return repository.findByStockLessThan(threshold, pageable); + } + + @Override + public List findAllById(Collection ids) { + return repository.findAllById(ids); + } + + @Override + public List findVariantsByIds(Collection variantIds) { + return variantRepository.findAllById(variantIds); + } + + @Override + public int findNewProducts(Instant startDate, Instant endDate) { + return repository.countNewProducts(startDate, endDate); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaProductRepository.java b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaProductRepository.java new file mode 100644 index 0000000..956c7d0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaProductRepository.java @@ -0,0 +1,32 @@ +package com.zenfulcode.commercify.product.infrastructure.persistence; + +import com.zenfulcode.commercify.product.domain.model.Product; +import com.zenfulcode.commercify.product.domain.valueobject.CategoryId; +import com.zenfulcode.commercify.product.domain.valueobject.ProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; + +@Repository +interface SpringDataJpaProductRepository extends JpaRepository { + Page findByActiveTrue(Pageable pageable); + + Page findByCategoryId(CategoryId categoryId, Pageable pageable); + + Page findByStockLessThan(int threshold, Pageable pageable); + + @Query(""" + SELECT COUNT(p) + FROM Product p + WHERE p.createdAt BETWEEN :startDate AND :endDate + """) + int countNewProducts( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate + ); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaVariantRepository.java b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaVariantRepository.java new file mode 100644 index 0000000..79231f6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/product/infrastructure/persistence/SpringDataJpaVariantRepository.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.product.infrastructure.persistence; + +import com.zenfulcode.commercify.product.domain.model.ProductVariant; +import com.zenfulcode.commercify.product.domain.valueobject.VariantId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +interface SpringDataJpaVariantRepository extends JpaRepository { +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEvent.java b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEvent.java new file mode 100644 index 0000000..b931dae --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEvent.java @@ -0,0 +1,32 @@ +package com.zenfulcode.commercify.shared.domain.event; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +import java.time.Instant; +import java.util.UUID; + +@Getter +public abstract class DomainEvent extends ApplicationEvent { + private final String eventId; + private final long occurredOn; + private final String eventType; + + protected DomainEvent(Object source) { + super(source); + this.eventId = UUID.randomUUID().toString(); + this.occurredOn = getTimestamp(); + this.eventType = this.getClass().getSimpleName(); + } + + @Override + @JsonIgnore + public Object getSource() { + return super.getSource(); + } + + public Instant getOccurredOn() { + return Instant.ofEpochMilli(occurredOn); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventHandler.java b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventHandler.java new file mode 100644 index 0000000..f95d9b5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventHandler.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.event; + +public interface DomainEventHandler { + void handle(DomainEvent event); + + boolean canHandle(DomainEvent event); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventPublisher.java b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventPublisher.java new file mode 100644 index 0000000..0473d52 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventPublisher.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.shared.domain.event; + +import java.util.List; + +public interface DomainEventPublisher { + void publish(DomainEvent event); + void publish(List events); +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventStore.java b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventStore.java new file mode 100644 index 0000000..6633828 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/event/DomainEventStore.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.shared.domain.event; + +import java.util.List; + +public interface DomainEventStore { + void store(DomainEvent event); + List getEvents(String aggregateId, String aggregateType); +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainException.java new file mode 100644 index 0000000..693b9f9 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainException.java @@ -0,0 +1,12 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public abstract class DomainException extends RuntimeException { + + public DomainException(String message) { + super(message); + } + + public DomainException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainForbiddenException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainForbiddenException.java new file mode 100644 index 0000000..fa9f886 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainForbiddenException.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public class DomainForbiddenException extends DomainException { + public DomainForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainInvariantViolationException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainInvariantViolationException.java new file mode 100644 index 0000000..ce0383b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainInvariantViolationException.java @@ -0,0 +1,13 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +import lombok.Getter; + +@Getter +public class DomainInvariantViolationException extends DomainException { + private final String invariantName; + + public DomainInvariantViolationException(String invariantName, String message) { + super(message); + this.invariantName = invariantName; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainValidationException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainValidationException.java new file mode 100644 index 0000000..eec40d5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/DomainValidationException.java @@ -0,0 +1,16 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +import lombok.Getter; + +import java.util.Collections; +import java.util.List; + +@Getter +public abstract class DomainValidationException extends DomainException { + private final List violations; + + public DomainValidationException(String message, List violations) { + super(message); + this.violations = Collections.unmodifiableList(violations); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EmailSendingException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EmailSendingException.java new file mode 100644 index 0000000..80d3b84 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EmailSendingException.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public class EmailSendingException extends DomainException { + public EmailSendingException(String message) { + super(message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EntityNotFoundException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EntityNotFoundException.java new file mode 100644 index 0000000..8deb29a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EntityNotFoundException.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +import lombok.Getter; + +@Getter +public class EntityNotFoundException extends DomainException { + private final String entityType; + private final Object entityId; + + public EntityNotFoundException(String entityType, Object entityId) { + super(String.format("%s with id %s not found", entityType, entityId)); + this.entityType = entityType; + this.entityId = entityId; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventDeserializationException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventDeserializationException.java new file mode 100644 index 0000000..b4855b4 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventDeserializationException.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public class EventDeserializationException extends DomainException { + public EventDeserializationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventSerializationException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventSerializationException.java new file mode 100644 index 0000000..6ebb843 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/EventSerializationException.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public class EventSerializationException extends DomainException { + public EventSerializationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/exception/UserAccountLockedException.java b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/UserAccountLockedException.java new file mode 100644 index 0000000..e9e69bf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/exception/UserAccountLockedException.java @@ -0,0 +1,7 @@ +package com.zenfulcode.commercify.shared.domain.exception; + +public class UserAccountLockedException extends DomainException { + public UserAccountLockedException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/model/AggregateRoot.java b/src/main/java/com/zenfulcode/commercify/shared/domain/model/AggregateRoot.java new file mode 100644 index 0000000..793732a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/model/AggregateRoot.java @@ -0,0 +1,24 @@ +package com.zenfulcode.commercify.shared.domain.model; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import org.springframework.data.annotation.Transient; + +import java.util.ArrayList; +import java.util.List; + +public abstract class AggregateRoot { + @Transient + private final List domainEvents = new ArrayList<>(); + + public void registerEvent(DomainEvent event) { + domainEvents.add(event); + } + + public List getDomainEvents() { + return new ArrayList<>(domainEvents); + } + + public void clearDomainEvents() { + domainEvents.clear(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/model/Money.java b/src/main/java/com/zenfulcode/commercify/shared/domain/model/Money.java new file mode 100644 index 0000000..f9cf37a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/model/Money.java @@ -0,0 +1,135 @@ +package com.zenfulcode.commercify.shared.domain.model; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + private BigDecimal amount; + private String currency; + + public Money(BigDecimal amount, String currency) { + this.amount = Objects.requireNonNull(amount).setScale(2, RoundingMode.HALF_UP); + this.currency = Objects.requireNonNull(currency); + validate(); + } + + public Money(double amount, String currency) { + this(BigDecimal.valueOf(amount), currency); + } + + private void validate() { + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("Currency is required"); + } + if (currency.length() != 3) { + throw new IllegalArgumentException("Currency must be a 3-letter ISO code"); + } + } + + public static Money zero(String currency) { + return new Money(BigDecimal.ZERO, currency); + } + + public static Money of(BigDecimal amount, String currency) { + return new Money(amount, currency); + } + + public static Money of(double amount, String currency) { + return new Money(BigDecimal.valueOf(amount), currency); + } + + public Money add(Money other) { + validateSameCurrency(other); + return new Money(this.amount.add(other.amount), this.currency); + } + + public Money subtract(Money other) { + validateSameCurrency(other); + return new Money(this.amount.subtract(other.amount), this.currency); + } + + public Money multiply(BigDecimal multiplier) { + return new Money(this.amount.multiply(multiplier), this.currency); + } + + public Money multiply(int multiplier) { + return multiply(BigDecimal.valueOf(multiplier)); + } + + public Money multiply(double multiplier) { + return multiply(BigDecimal.valueOf(multiplier)); + } + + public Money percentage(BigDecimal percentage) { + BigDecimal multiplier = percentage.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP); + return multiply(multiplier); + } + + public boolean isGreaterThan(Money other) { + validateSameCurrency(other); + return this.amount.compareTo(other.amount) > 0; + } + + public boolean isGreaterThanOrEqual(Money other) { + validateSameCurrency(other); + return this.amount.compareTo(other.amount) >= 0; + } + + public boolean isLessThan(Money other) { + validateSameCurrency(other); + return this.amount.compareTo(other.amount) < 0; + } + + public boolean isLessThanOrEqual(Money other) { + validateSameCurrency(other); + return this.amount.compareTo(other.amount) <= 0; + } + + public boolean isZero() { + return amount.compareTo(BigDecimal.ZERO) == 0; + } + + public boolean isPositive() { + return amount.compareTo(BigDecimal.ZERO) > 0; + } + + public boolean isNegative() { + return amount.compareTo(BigDecimal.ZERO) < 0; + } + + private void validateSameCurrency(Money other) { + if (!this.currency.equals(other.currency)) { + throw new IllegalArgumentException( + String.format("Currency mismatch: %s vs %s", this.currency, other.currency) + ); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Money money = (Money) o; + return amount.compareTo(money.amount) == 0 && + currency.equals(money.currency); + } + + @Override + public int hashCode() { + return Objects.hash(amount, currency); + } + + @Override + public String toString() { + return amount.toString() + " " + currency; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/model/StoredEvent.java b/src/main/java/com/zenfulcode/commercify/shared/domain/model/StoredEvent.java new file mode 100644 index 0000000..0691a5b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/model/StoredEvent.java @@ -0,0 +1,50 @@ +package com.zenfulcode.commercify.shared.domain.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@Entity +@Table(name = "domain_events") +@NoArgsConstructor +public class StoredEvent { + @Id + @Column(name = "event_id", nullable = false) + private String eventId; + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Lob + @Column(name = "event_data", nullable = false) + private String eventData; + + @Column(name = "occurred_on", nullable = false) + private Instant occurredOn; + + @Column(name = "aggregate_id") + private String aggregateId; + + @Column(name = "aggregate_type") + private String aggregateType; + + public StoredEvent( + String eventId, + String eventType, + String eventData, + Instant occurredOn, + String aggregateId, + String aggregateType) { + this.eventId = eventId; + this.eventType = eventType; + this.eventData = eventData; + this.occurredOn = occurredOn; + this.aggregateId = aggregateId; + this.aggregateType = aggregateType; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/service/AggregateReference.java b/src/main/java/com/zenfulcode/commercify/shared/domain/service/AggregateReference.java new file mode 100644 index 0000000..b4ccc28 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/service/AggregateReference.java @@ -0,0 +1,58 @@ +package com.zenfulcode.commercify.shared.domain.service; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.model.AggregateRoot; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; + +import java.lang.reflect.Field; +import java.util.Arrays; + +public class AggregateReference { + + public static String extractId(DomainEvent event) { + // First try to find a field ending with "Id" + String id = Arrays.stream(event.getClass().getDeclaredFields()) + .filter(field -> field.getName().toLowerCase().endsWith("id")) + .findFirst() + .map(field -> getFieldValue(field, event)) + .map(Object::toString) + .orElse(null); + + if (id == null) { + // Fallback to any field annotated with @AggregateId if exists + id = Arrays.stream(event.getClass().getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(AggregateId.class)) + .findFirst() + .map(field -> getFieldValue(field, event)) + .map(Object::toString) + .orElse(event.getEventId()); // Use event ID as last resort + } + + return id; + } + + public static String extractType(DomainEvent event) { + // Try to extract from class name (e.g., OrderCreatedEvent -> Order) + String className = event.getClass().getSimpleName(); + if (className.endsWith("Event")) { + return className.substring(0, className.length() - "Event".length()); + } + + // Fallback to any field that is an AggregateRoot + return Arrays.stream(event.getClass().getDeclaredFields()) + .filter(field -> AggregateRoot.class.isAssignableFrom(field.getType())) + .findFirst() + .map(Field::getType) + .map(Class::getSimpleName) + .orElse("Unknown"); + } + + private static Object getFieldValue(Field field, Object target) { + try { + field.setAccessible(true); + return field.get(target); + } catch (IllegalAccessException e) { + throw new RuntimeException("Could not access field: " + field.getName(), e); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/service/DefaultDomainEventPublisher.java b/src/main/java/com/zenfulcode/commercify/shared/domain/service/DefaultDomainEventPublisher.java new file mode 100644 index 0000000..cdb4370 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/service/DefaultDomainEventPublisher.java @@ -0,0 +1,45 @@ +package com.zenfulcode.commercify.shared.domain.service; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import com.zenfulcode.commercify.shared.domain.event.DomainEventStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultDomainEventPublisher implements DomainEventPublisher { + private final ApplicationEventPublisher eventPublisher; + private final DomainEventStore eventStore; + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void publish(DomainEvent event) { + try { + log.info("Publishing event: {}", event.getEventType()); + + // Store the event + eventStore.store(event); + + // Publish to Spring's event system + eventPublisher.publishEvent(event); + + log.info("Successfully published event: {}", event.getEventType()); + } catch (Exception e) { + log.error("Failed to publish event: {}", event.getEventType(), e); + throw e; + } + } + + @Override + public void publish(List events) { + events.forEach(this::publish); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/domain/valueobject/AggregateId.java b/src/main/java/com/zenfulcode/commercify/shared/domain/valueobject/AggregateId.java new file mode 100644 index 0000000..02298a0 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/domain/valueobject/AggregateId.java @@ -0,0 +1,11 @@ +package com.zenfulcode.commercify.shared.domain.valueobject; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface AggregateId { +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/config/MailConfig.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/config/MailConfig.java new file mode 100644 index 0000000..f62563c --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/config/MailConfig.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.shared.infrastructure.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private boolean auth; + + @Value("${spring.mail.properties.mail.smtp.starttls.enable}") + private boolean starttls; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", auth); + props.put("mail.smtp.starttls.enable", starttls); + props.put("mail.debug", "false"); // Set to true for debugging + + return mailSender; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/EventStoreRepository.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/EventStoreRepository.java new file mode 100644 index 0000000..b8988c1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/EventStoreRepository.java @@ -0,0 +1,55 @@ +package com.zenfulcode.commercify.shared.infrastructure.persistence; + +import com.zenfulcode.commercify.shared.domain.model.StoredEvent; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface EventStoreRepository extends JpaRepository { + + List findByAggregateIdAndAggregateType(String aggregateId, String aggregateType); + + @Query("SELECT e FROM StoredEvent e WHERE e.occurredOn >= :since ORDER BY e.occurredOn ASC") + List findEventsSince(@Param("since") Instant since); + + @Query("SELECT e FROM StoredEvent e WHERE e.eventType = :eventType ORDER BY e.occurredOn ASC") + List findByEventType(@Param("eventType") String eventType); + + @Query("SELECT e FROM StoredEvent e WHERE e.aggregateType = :aggregateType ORDER BY e.occurredOn ASC") + Page findByAggregateType( + @Param("aggregateType") String aggregateType, + Pageable pageable + ); + + @Query(""" + SELECT e FROM StoredEvent e + WHERE e.aggregateId = :aggregateId + AND e.aggregateType = :aggregateType + AND e.occurredOn >= :since + ORDER BY e.occurredOn ASC + """) + List findByAggregateIdAndTypeSince( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType, + @Param("since") Instant since + ); + + @Query(""" + SELECT COUNT(e) > 0 FROM StoredEvent e + WHERE e.aggregateId = :aggregateId + AND e.aggregateType = :aggregateType + AND e.eventType = :eventType + """) + boolean hasEventType( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType, + @Param("eventType") String eventType + ); +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/JpaDomainEventStore.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/JpaDomainEventStore.java new file mode 100644 index 0000000..e86a9e6 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/persistence/JpaDomainEventStore.java @@ -0,0 +1,53 @@ +package com.zenfulcode.commercify.shared.infrastructure.persistence; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.event.DomainEventStore; +import com.zenfulcode.commercify.shared.domain.model.StoredEvent; +import com.zenfulcode.commercify.shared.infrastructure.service.EventSerializer; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JpaDomainEventStore implements DomainEventStore { + private final EventStoreRepository repository; + private final EventSerializer eventSerializer; + + @Async + @EventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleDomainEvent(DomainEvent event) { + store(event); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void store(DomainEvent event) { + StoredEvent storedEvent = eventSerializer.serialize(event); + repository.save(storedEvent); + } + + @Override + @Transactional(readOnly = true) + public List getEvents(String aggregateId, String aggregateType) { + return repository.findByAggregateIdAndAggregateType(aggregateId, aggregateType) + .stream() + .map(eventSerializer::deserialize) + .toList(); + } + + @Transactional(readOnly = true) + public List getEventsSince(Instant since) { + return repository.findEventsSince(since) + .stream() + .map(eventSerializer::deserialize) + .toList(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EmailService.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EmailService.java new file mode 100644 index 0000000..25d250e --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EmailService.java @@ -0,0 +1,27 @@ +package com.zenfulcode.commercify.shared.infrastructure.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class EmailService { + private final JavaMailSender mailSender; + + public void sendEmail(String to, String subject, String content, boolean isHtml) throws MessagingException { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, isHtml); + + mailSender.send(mimeMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventSerializer.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventSerializer.java new file mode 100644 index 0000000..837a0a4 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventSerializer.java @@ -0,0 +1,52 @@ +package com.zenfulcode.commercify.shared.infrastructure.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.exception.EventDeserializationException; +import com.zenfulcode.commercify.shared.domain.exception.EventSerializationException; +import com.zenfulcode.commercify.shared.domain.model.StoredEvent; +import com.zenfulcode.commercify.shared.domain.service.AggregateReference; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EventSerializer { + private final ObjectMapper objectMapper; + private final EventTypeResolver eventTypeResolver; + + public StoredEvent serialize(DomainEvent event) { + try { + String eventData = objectMapper.writeValueAsString(event); + + return new StoredEvent( + event.getEventId(), + event.getClass().getName(), + eventData, + event.getOccurredOn(), + extractAggregateId(event), + extractAggregateType(event) + ); + } catch (Exception e) { + throw new EventSerializationException("Failed to serialize event", e); + } + } + + public DomainEvent deserialize(StoredEvent storedEvent) { + try { + Class eventClass = eventTypeResolver.resolveEventClass(storedEvent.getEventType()); + return (DomainEvent) objectMapper.readValue(storedEvent.getEventData(), eventClass); + } catch (Exception e) { + throw new EventDeserializationException( + "Failed to deserialize event: " + storedEvent.getEventType(), e); + } + } + + private String extractAggregateId(DomainEvent event) { + return AggregateReference.extractId(event); + } + + private String extractAggregateType(DomainEvent event) { + return AggregateReference.extractType(event); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventTypeResolver.java b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventTypeResolver.java new file mode 100644 index 0000000..d907a67 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/infrastructure/service/EventTypeResolver.java @@ -0,0 +1,44 @@ +package com.zenfulcode.commercify.shared.infrastructure.service; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.exception.EventDeserializationException; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class EventTypeResolver { + private final Map> eventTypeMap = new ConcurrentHashMap<>(); + + public Class resolveEventClass(String eventType) { + return eventTypeMap.computeIfAbsent(eventType, type -> { + try { + return loadEventClass(type); + } catch (ClassNotFoundException e) { + throw new EventDeserializationException("Failed to load event class: " + type, e); + } + }); + } + + @SuppressWarnings("unchecked") + private Class loadEventClass(String eventType) throws ClassNotFoundException { + Class loadedClass = Class.forName(eventType); + if (!DomainEvent.class.isAssignableFrom(loadedClass)) { + throw new IllegalArgumentException("Invalid event type: " + eventType); + } + return (Class) loadedClass; + } + + public void registerEventType(String eventType, Class eventClass) { + eventTypeMap.put(eventType, eventClass); + } + + public boolean isRegistered(String eventType) { + return eventTypeMap.containsKey(eventType); + } + + public void clearRegistrations() { + eventTypeMap.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/interfaces/ApiResponse.java b/src/main/java/com/zenfulcode/commercify/shared/interfaces/ApiResponse.java new file mode 100644 index 0000000..ae93a1a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/interfaces/ApiResponse.java @@ -0,0 +1,148 @@ +package com.zenfulcode.commercify.shared.interfaces; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; + +import java.time.Instant; +import java.util.List; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + private final String status; + private final T data; + private final ErrorInfo error; + private final MetaInfo meta; + private final Instant timestamp; + + private ApiResponse(String status, T data, ErrorInfo error, MetaInfo meta) { + this.status = status; + this.data = data; + this.error = error; + this.meta = meta; + this.timestamp = Instant.now(); + } + + // Success response with data + public static ApiResponse success(T data) { + return new ApiResponse<>("success", data, null, null); + } + + // Success response with data and metadata + public static ApiResponse success(T data, MetaInfo meta) { + return new ApiResponse<>("success", data, null, meta); + } + + // Error response + public static ApiResponse error(ErrorInfo error) { + return new ApiResponse<>("error", null, error, null); + } + + // Error response with http status + public static ApiResponse error(String message, String code, int httpStatus) { + ErrorInfo error = new ErrorInfo(message, code, httpStatus); + return new ApiResponse<>("error", null, error, null); + } + + // Validation error response + public static ApiResponse validationError(List validationErrors) { + ErrorInfo error = new ErrorInfo( + "Validation failed", + "VALIDATION_ERROR", + 400, + validationErrors + ); + return new ApiResponse<>("error", null, error, null); + } + + @Getter + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ErrorInfo { + private final String message; + private final String code; + private final int httpStatus; + private final List validationErrors; + + public ErrorInfo(String message, String code, int httpStatus) { + this(message, code, httpStatus, null); + } + + public ErrorInfo(String message, String code, int httpStatus, + List validationErrors) { + this.message = message; + this.code = code; + this.httpStatus = httpStatus; + this.validationErrors = validationErrors; + } + } + + public record ValidationError(String field, String message) { + } + + @Getter + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class MetaInfo { + private final Integer page; + private final Integer size; + private final Long totalElements; + private final Integer totalPages; + private final String nextPage; + private final String previousPage; + + private MetaInfo(Builder builder) { + this.page = builder.page; + this.size = builder.size; + this.totalElements = builder.totalElements; + this.totalPages = builder.totalPages; + this.nextPage = builder.nextPage; + this.previousPage = builder.previousPage; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Integer page; + private Integer size; + private Long totalElements; + private Integer totalPages; + private String nextPage; + private String previousPage; + + public Builder page(Integer page) { + this.page = page; + return this; + } + + public Builder size(Integer size) { + this.size = size; + return this; + } + + public Builder totalElements(Long totalElements) { + this.totalElements = totalElements; + return this; + } + + public Builder totalPages(Integer totalPages) { + this.totalPages = totalPages; + return this; + } + + public Builder nextPage(String nextPage) { + this.nextPage = nextPage; + return this; + } + + public Builder previousPage(String previousPage) { + this.previousPage = previousPage; + return this; + } + + public MetaInfo build() { + return new MetaInfo(this); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/shared/interfaces/rest/exception/GlobalExceptionHandler.java b/src/main/java/com/zenfulcode/commercify/shared/interfaces/rest/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ac84d41 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/shared/interfaces/rest/exception/GlobalExceptionHandler.java @@ -0,0 +1,68 @@ +package com.zenfulcode.commercify.shared.interfaces.rest.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import com.zenfulcode.commercify.shared.domain.exception.DomainForbiddenException; +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; +import com.zenfulcode.commercify.shared.domain.exception.EntityNotFoundException; +import com.zenfulcode.commercify.shared.interfaces.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomainException(DomainException ex) { + ApiResponse response = ApiResponse.error( + ex.getMessage(), + "DOMAIN_ERROR", + 400 + ); + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(DomainValidationException.class) + public ResponseEntity> handleValidationException( + DomainValidationException ex) { + List validationErrors = ex.getViolations() + .stream() + .map(error -> + new ApiResponse.ValidationError( + error, + "VALIDATION_ERROR" + ) + ) + .collect(Collectors.toList()); + + ApiResponse response = ApiResponse.validationError(validationErrors); + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity> handleNotFoundException( + EntityNotFoundException ex) { + ApiResponse response = ApiResponse.error( + ex.getMessage(), + "NOT_FOUND", + 404 + ); + + return ResponseEntity.status(404).body(response); + } + + @ExceptionHandler(DomainForbiddenException.class) + public ResponseEntity> handleUnauthorizedOrderCreation(DomainForbiddenException ex) { + ApiResponse response = ApiResponse.error( + ex.getMessage(), + "UNAUTHORIZED_ORDER_CREATION", + 403 + ); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/application/command/CreateUserCommand.java b/src/main/java/com/zenfulcode/commercify/user/application/command/CreateUserCommand.java new file mode 100644 index 0000000..0063a9a --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/application/command/CreateUserCommand.java @@ -0,0 +1,16 @@ +package com.zenfulcode.commercify.user.application.command; + +import com.zenfulcode.commercify.user.domain.model.UserRole; + +import java.util.Set; + + +public record CreateUserCommand( + String email, + String firstName, + String lastName, + String password, + Set roles, + String phoneNumber +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserCommand.java b/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserCommand.java new file mode 100644 index 0000000..0120ce5 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserCommand.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.user.application.command; + +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import com.zenfulcode.commercify.user.domain.valueobject.UserSpecification; + +public record UpdateUserCommand( + UserId userId, + UserSpecification userSpec +) { +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserStatusCommand.java b/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserStatusCommand.java new file mode 100644 index 0000000..ab64538 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/application/command/UpdateUserStatusCommand.java @@ -0,0 +1,10 @@ +package com.zenfulcode.commercify.user.application.command; + +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; + +public record UpdateUserStatusCommand( + UserId userId, + UserStatus newStatus +) { +} diff --git a/src/main/java/com/zenfulcode/commercify/user/application/query/CountActiveUsersInPeriodQuery.java b/src/main/java/com/zenfulcode/commercify/user/application/query/CountActiveUsersInPeriodQuery.java new file mode 100644 index 0000000..7999c08 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/application/query/CountActiveUsersInPeriodQuery.java @@ -0,0 +1,19 @@ +package com.zenfulcode.commercify.user.application.query; + +import com.zenfulcode.commercify.metrics.application.dto.MetricsQuery; + +import java.time.LocalDate; + +public record CountActiveUsersInPeriodQuery( + String region, + LocalDate startDate, + LocalDate endDate +) { + public static CountActiveUsersInPeriodQuery of(MetricsQuery metricsQuery) { + return new CountActiveUsersInPeriodQuery( + metricsQuery.region(), + metricsQuery.startDate(), + metricsQuery.endDate() + ); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/application/service/UserApplicationService.java b/src/main/java/com/zenfulcode/commercify/user/application/service/UserApplicationService.java new file mode 100644 index 0000000..d10e9cb --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/application/service/UserApplicationService.java @@ -0,0 +1,172 @@ +package com.zenfulcode.commercify.user.application.service; + +import com.zenfulcode.commercify.shared.domain.event.DomainEventPublisher; +import com.zenfulcode.commercify.user.application.command.CreateUserCommand; +import com.zenfulcode.commercify.user.application.command.UpdateUserCommand; +import com.zenfulcode.commercify.user.application.command.UpdateUserStatusCommand; +import com.zenfulcode.commercify.user.application.query.CountActiveUsersInPeriodQuery; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserRole; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.service.UserDomainService; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import com.zenfulcode.commercify.user.domain.valueobject.UserSpecification; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class UserApplicationService { + private final UserDomainService userDomainService; + private final DomainEventPublisher eventPublisher; + private final PasswordEncoder passwordEncoder; + + /** + * Creates a new user + */ + @Transactional + public UserId createUser(CreateUserCommand command) { + // Hash the password + String hashedPassword = passwordEncoder.encode(command.password()); + + UserSpecification userSpecification = new UserSpecification(command.firstName(), command.lastName(), command.email(), hashedPassword, command.phoneNumber(), UserStatus.PENDING, command.roles()); + + // Create the user through domain service + User user = userDomainService.createUser(userSpecification); + + // Publish domain events + eventPublisher.publish(user.getDomainEvents()); + return user.getId(); + } + + @Transactional + public void registerUser(String firstName, String lastName, String email, String password, String phone) { + CreateUserCommand createUserCommand = new CreateUserCommand(email, firstName, lastName, password, Set.of(UserRole.USER), phone); + + createUser(createUserCommand); + } + + /** + * Updates an existing user + */ + @Transactional + public void updateUser(UpdateUserCommand command) { + // Retrieve user + User user = getUser(command.userId()); + + // Update through domain service + userDomainService.updateUserInfo(user, command.userSpec()); + + // Publish events + eventPublisher.publish(user.getDomainEvents()); + } + + /** + * Updates user status (activate/deactivate) + */ + @Transactional + public void updateUserStatus(UpdateUserStatusCommand command) { + User user = userDomainService.getUserById(command.userId()); + + userDomainService.updateUserStatus(user, command.newStatus()); + eventPublisher.publish(user.getDomainEvents()); + } + + /** + * Activates a user + */ + @Transactional + public void activateUser(UserId userId) { + updateUserStatus(new UpdateUserStatusCommand(userId, UserStatus.ACTIVE)); + } + + /** + * Deactivates a user + */ + @Transactional + public void deactivateUser(UserId userId) { + updateUserStatus(new UpdateUserStatusCommand(userId, UserStatus.DEACTIVATED)); + } + + /** + * Gets a user by ID + */ + @Transactional(readOnly = true) + public User getUser(UserId userId) { + return userDomainService.getUserById(userId); + } + + /** + * Gets a user by email + */ + @Transactional(readOnly = true) + public User getUserByEmail(String email) { + return userDomainService.getUserByEmail(email); + } + + /** + * Lists all users with pagination + */ + @Transactional(readOnly = true) + public Page getAllUsers(Pageable pageable) { + return userDomainService.getAllUsers(pageable); + } + + /** + * Lists active users with pagination + */ + @Transactional(readOnly = true) + public Page getActiveUsers(Pageable pageable) { + return userDomainService.getUsersByStatus(UserStatus.ACTIVE, pageable); + } + + /** + * Checks if email exists + */ + @Transactional(readOnly = true) + public boolean emailExists(String email) { + return userDomainService.emailExists(email); + } + + /** + * Changes user password + */ + @Transactional + public void changePassword(UserId userId, String currentPassword, String newPassword) { + User user = getUser(userId); + + // Verify current password + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new IllegalArgumentException("Current password is incorrect"); + } + + // Hash new password and update + String hashedPassword = passwordEncoder.encode(newPassword); + userDomainService.changePassword(user, hashedPassword); + + eventPublisher.publish(user.getDomainEvents()); + } + + /** + * Resets user password (admin function) + */ + @Transactional + public void resetPassword(UserId userId, String newPassword) { + User user = getUser(userId); + + String hashedPassword = passwordEncoder.encode(newPassword); + userDomainService.changePassword(user, hashedPassword); + + eventPublisher.publish(user.getDomainEvents()); + } + + public int countActiveUsersInPeriod(CountActiveUsersInPeriodQuery query) { + return userDomainService.findNewUsers(query.startDate(), query.endDate()); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/event/UserCreatedEvent.java b/src/main/java/com/zenfulcode/commercify/user/domain/event/UserCreatedEvent.java new file mode 100644 index 0000000..1207cce --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/event/UserCreatedEvent.java @@ -0,0 +1,27 @@ +package com.zenfulcode.commercify.user.domain.event; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +@Getter +public class UserCreatedEvent extends DomainEvent { + @AggregateId + private final UserId userId; + private final String email; + private final UserStatus status; + + public UserCreatedEvent(Object source, UserId userId, String email, UserStatus status) { + super(source); + this.userId = userId; + this.email = email; + this.status = status; + } + + @Override + public String getEventType() { + return "USER_CREATED"; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/event/UserStatusChangedEvent.java b/src/main/java/com/zenfulcode/commercify/user/domain/event/UserStatusChangedEvent.java new file mode 100644 index 0000000..3807827 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/event/UserStatusChangedEvent.java @@ -0,0 +1,48 @@ +package com.zenfulcode.commercify.user.domain.event; + +import com.zenfulcode.commercify.shared.domain.event.DomainEvent; +import com.zenfulcode.commercify.shared.domain.valueobject.AggregateId; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +import java.time.Instant; + +@Getter +public class UserStatusChangedEvent extends DomainEvent { + @AggregateId + private final UserId userId; + private final UserStatus oldStatus; + private final UserStatus newStatus; + private final Instant changedAt; + + public UserStatusChangedEvent( + Object source, + UserId userId, + UserStatus oldStatus, + UserStatus newStatus + ) { + super(source); + this.userId = userId; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedAt = Instant.now(); + } + + public boolean isDeactivationTransition() { + return newStatus == UserStatus.DEACTIVATED; + } + + public boolean isSuspensionTransition() { + return newStatus == UserStatus.SUSPENDED; + } + + public boolean isActivationTransition() { + return newStatus == UserStatus.ACTIVE; + } + + @Override + public String getEventType() { + return "USER_STATUS_CHANGED"; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateException.java b/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateException.java new file mode 100644 index 0000000..abd1dac --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateException.java @@ -0,0 +1,27 @@ +package com.zenfulcode.commercify.user.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +@Getter +public class InvalidUserStateException extends DomainException { + private final UserId userId; + private final UserStatus currentStatus; + private final UserStatus targetStatus; + + public InvalidUserStateException( + UserId userId, + UserStatus currentStatus, + UserStatus targetStatus, + String message + ) { + super(String.format("Invalid user status transition from %s to %s for user %s: %s", + currentStatus, targetStatus, userId, message)); + + this.userId = userId; + this.currentStatus = currentStatus; + this.targetStatus = targetStatus; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateTransitionException.java b/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateTransitionException.java new file mode 100644 index 0000000..5145172 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/exception/InvalidUserStateTransitionException.java @@ -0,0 +1,27 @@ +package com.zenfulcode.commercify.user.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +@Getter +public class InvalidUserStateTransitionException extends DomainException { + private final UserId userId; + private final UserStatus currentStatus; + private final UserStatus targetStatus; + + public InvalidUserStateTransitionException( + UserId userId, + UserStatus currentStatus, + UserStatus targetStatus, + String message + ) { + super(String.format("Invalid user status transition from %s to %s for user %s: %s", + currentStatus, targetStatus, userId, message)); + + this.userId = userId; + this.currentStatus = currentStatus; + this.targetStatus = targetStatus; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserAlreadyExistsException.java b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..23d8c7b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.user.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; + +public class UserAlreadyExistsException extends DomainException { + public UserAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserNotFoundException.java b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserNotFoundException.java new file mode 100644 index 0000000..f0bd051 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserNotFoundException.java @@ -0,0 +1,25 @@ +package com.zenfulcode.commercify.user.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.Getter; + +@Getter +public class UserNotFoundException extends DomainException { + private final Object identifier; + + public UserNotFoundException(UserId userId) { + super("User not found with ID: " + userId); + this.identifier = userId; + } + + public UserNotFoundException(String email) { + super("User not found with Email: " + email); + this.identifier = email; + } + + public UserNotFoundException(String message, String identifier) { + super(message + ": " + identifier); + this.identifier = identifier; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserValidationException.java b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserValidationException.java new file mode 100644 index 0000000..452b3a7 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/exception/UserValidationException.java @@ -0,0 +1,15 @@ +package com.zenfulcode.commercify.user.domain.exception; + +import com.zenfulcode.commercify.shared.domain.exception.DomainValidationException; + +import java.util.List; + +public class UserValidationException extends DomainValidationException { + public UserValidationException(String message) { + super(message, List.of(message)); + } + + public UserValidationException(List violations) { + super("User validation failed: " + String.join(", ", violations), violations); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/model/User.java b/src/main/java/com/zenfulcode/commercify/user/domain/model/User.java new file mode 100644 index 0000000..f03bdcb --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/model/User.java @@ -0,0 +1,188 @@ +package com.zenfulcode.commercify.user.domain.model; + +import com.zenfulcode.commercify.order.domain.model.Order; +import com.zenfulcode.commercify.order.domain.model.OrderStatus; +import com.zenfulcode.commercify.shared.domain.model.AggregateRoot; +import com.zenfulcode.commercify.user.domain.event.UserCreatedEvent; +import com.zenfulcode.commercify.user.domain.event.UserStatusChangedEvent; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "users", uniqueConstraints = { + @UniqueConstraint(name = "uc_users_email", columnNames = {"email"}) +}) +public class User extends AggregateRoot { + @EmbeddedId + private UserId id; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "first_name", nullable = false) + private String firstName; + + @Column(name = "last_name", nullable = false) + private String lastName; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "phone_number") + private String phoneNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private UserStatus status; + + @CreationTimestamp + @Column(name = "created_at") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + @Column(name = "last_login_at") + private Instant lastLoginAt; + + @OneToMany(mappedBy = "user") + private Set orders = new LinkedHashSet<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id") + ) + @Enumerated(EnumType.STRING) + @Column(name = "role") + private Set roles = new HashSet<>(); + + public static User create( + String firstName, + String lastName, + String email, + String password, + Set roles, + String phoneNumber + ) { + User user = new User(); + user.id = UserId.generate(); + user.email = Objects.requireNonNull(email, "Email is required").toLowerCase(); + user.firstName = Objects.requireNonNull(firstName, "First name is required"); + user.lastName = Objects.requireNonNull(lastName, "Last name is required"); + user.password = Objects.requireNonNull(password, "Password is required"); + user.status = UserStatus.PENDING; + user.roles = new HashSet<>(roles != null ? roles : Set.of(UserRole.USER)); + user.phoneNumber = phoneNumber; + + // Register domain event + user.registerEvent(new UserCreatedEvent( + user, + user.getId(), + user.getEmail(), + user.getStatus() + )); + + return user; + } + + // Domain methods + public void updateProfile(String firstName, String lastName) { + if (firstName != null) { + this.firstName = firstName; + } + if (lastName != null) { + this.lastName = lastName; + } + } + + public void updateEmail(String newEmail) { + this.email = Objects.requireNonNull(newEmail, "Email is required").toLowerCase(); + } + + public void updatePhone(String phone) { + this.phoneNumber = phone; + } + + public void updatePassword(String newPassword) { + this.password = Objects.requireNonNull(newPassword, "Password is required"); + } + + public void updateStatus(UserStatus newStatus) { + UserStatus oldStatus = this.status; + this.status = newStatus; + + registerEvent(new UserStatusChangedEvent( + this, + this.id, + oldStatus, + newStatus + )); + } + + public void updateRoles(Set newRoles) { + if (newRoles == null || newRoles.isEmpty()) { + throw new IllegalArgumentException("User must have at least one role"); + } + this.roles = new HashSet<>(newRoles); + } + + public void recordLogin() { + this.lastLoginAt = Instant.now(); + } + + public boolean hasRole(UserRole role) { + return roles.contains(role); + } + + public boolean isActive() { + return status == UserStatus.ACTIVE; + } + + public boolean hasOutstandingPayments() { + return false; + } + + public boolean hasActiveOrders() { + return orders.stream() + .anyMatch(order -> { + OrderStatus status = order.getStatus(); + return status == OrderStatus.PENDING || + status == OrderStatus.PAID || + status == OrderStatus.SHIPPED; + }); + } + + public String getUsername() { + return firstName + lastName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof User user)) return false; + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public String getFullName() { + return firstName + lastName; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/model/UserRole.java b/src/main/java/com/zenfulcode/commercify/user/domain/model/UserRole.java new file mode 100644 index 0000000..7dbd5dd --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/model/UserRole.java @@ -0,0 +1,9 @@ +package com.zenfulcode.commercify.user.domain.model; + +public enum UserRole { + GUEST, // Guest user + USER, // Standard user + ADMIN, // Administrator + MANAGER, // Store manager + SUPPORT // Customer support +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/model/UserStatus.java b/src/main/java/com/zenfulcode/commercify/user/domain/model/UserStatus.java new file mode 100644 index 0000000..24151c1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/model/UserStatus.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.user.domain.model; + +public enum UserStatus { + PENDING, // Initial state after registration + ACTIVE, // User is active and can use the system + SUSPENDED, // User is temporarily suspended + DEACTIVATED // User account is deactivated (terminal state) +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/repository/UserRepository.java b/src/main/java/com/zenfulcode/commercify/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..b5ffda1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/repository/UserRepository.java @@ -0,0 +1,28 @@ +package com.zenfulcode.commercify.user.domain.repository; + +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.Instant; +import java.util.Optional; + +public interface UserRepository { + User save(User user); + + Optional findById(UserId id); + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + Page findAll(Pageable pageable); + + Page findByStatus(UserStatus status, Pageable pageable); + + void delete(User user); + + int findNewUsers(Instant startDate, Instant endDate); +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/service/UserDomainService.java b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserDomainService.java new file mode 100644 index 0000000..0599bfb --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserDomainService.java @@ -0,0 +1,174 @@ +package com.zenfulcode.commercify.user.domain.service; + +import com.zenfulcode.commercify.user.domain.exception.UserAlreadyExistsException; +import com.zenfulcode.commercify.user.domain.exception.UserNotFoundException; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.repository.UserRepository; +import com.zenfulcode.commercify.user.domain.valueobject.UserDeletionValidation; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import com.zenfulcode.commercify.user.domain.valueobject.UserSpecification; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserDomainService { + private final UserValidationService validationService; + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + + /** + * Creates a new user with validation and enrichment + */ + public User createUser(UserSpecification spec) { + // Check if username or email already exists + if (userRepository.existsByEmail(spec.email())) { + throw new UserAlreadyExistsException("Email already exists"); + } + + // Create user with encrypted password + User user = User.create( + spec.firstName(), + spec.lastName(), + spec.email(), + spec.password(), + spec.roles(), + spec.phone() + ); + + // Validate user + validationService.validateCreateUser(user); + + // Save user + return userRepository.save(user); + } + + /** + * Updates user status with validation + */ + public void updateUserStatus(User user, UserStatus newStatus) { + // Validate status transition + validationService.validateStatusTransition(user, newStatus); + + // If transitioning to deactivated state, validate deactivation + if (newStatus == UserStatus.DEACTIVATED) { + validationService.validateDeactivation(user); + } + + user.updateStatus(newStatus); + userRepository.save(user); + } + + /** + * Updates user information with validation + */ + public void updateUserInfo(User user, UserSpecification updateSpec) { + // Update basic information if provided + if (updateSpec.hasBasicInfoUpdate()) { + user.updateProfile(updateSpec.firstName(), updateSpec.lastName()); + } + + if (updateSpec.hasContactInfoUpdate()) { + user.updateEmail(updateSpec.email()); + } + + if (updateSpec.phone() != null) { + user.updatePhone(updateSpec.phone()); + } + + // Validate updated user + validationService.validateUpdateUser(user); + userRepository.save(user); + } + + /** + * Changes user password with validation + */ + public void changePassword(User user, String newPassword) { + // Validate new password + validationService.validatePasswordChange(newPassword); + + // Update password + user.updatePassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } + + /** + * Validates if a user can be deleted + */ + public UserDeletionValidation validateUserDeletion(User user) { + List issues = new ArrayList<>(); + + try { + validationService.validateAccountDeletion(user); + } catch (Exception e) { + issues.add(e.getMessage()); + } + + return new UserDeletionValidation(issues.isEmpty(), issues); + } + + /** + * Deactivates a user account with validation + */ + public void deactivateUser(User user) { + validationService.validateDeactivation(user); + updateUserStatus(user, UserStatus.DEACTIVATED); + } + + /** + * Suspends a user account with validation + */ + public void suspendUser(User user) { + updateUserStatus(user, UserStatus.SUSPENDED); + } + + /** + * Reactivates a suspended user account with validation + */ + public void reactivateUser(User user) { + if (user.getStatus() != UserStatus.SUSPENDED) { + throw new IllegalStateException("Can only reactivate suspended users"); + } + updateUserStatus(user, UserStatus.ACTIVE); + } + + public User getUserById(UserId userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + } + + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("User not found with email", email)); + } + + public Page getAllUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + + public Page getUsersByStatus(UserStatus userStatus, Pageable pageable) { + return userRepository.findByStatus(userStatus, pageable); + } + + public boolean emailExists(String email) { + return userRepository.existsByEmail(email); + } + + public int findNewUsers(LocalDate startDate, LocalDate endDate) { + Instant start = startDate.atStartOfDay().toInstant(ZoneOffset.from(ZoneOffset.UTC)); + Instant end = endDate.atTime(23, 59).toInstant(ZoneOffset.UTC); + + return userRepository.findNewUsers(start, end); + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/service/UserStateFlow.java b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserStateFlow.java new file mode 100644 index 0000000..fd3b7e1 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserStateFlow.java @@ -0,0 +1,49 @@ +package com.zenfulcode.commercify.user.domain.service; + +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Set; + +@Component +public class UserStateFlow { + private final EnumMap> validTransitions; + + public UserStateFlow() { + validTransitions = new EnumMap<>(UserStatus.class); + + // Initial state -> Active or Suspended + validTransitions.put(UserStatus.PENDING, Set.of( + UserStatus.ACTIVE, + UserStatus.SUSPENDED + )); + + // Active -> Suspended or Deactivated + validTransitions.put(UserStatus.ACTIVE, Set.of( + UserStatus.SUSPENDED, + UserStatus.DEACTIVATED + )); + + // Suspended -> Active or Deactivated + validTransitions.put(UserStatus.SUSPENDED, Set.of( + UserStatus.ACTIVE, + UserStatus.DEACTIVATED + )); + + // Terminal state + validTransitions.put(UserStatus.DEACTIVATED, Set.of()); + } + + public boolean canTransition(UserStatus currentState, UserStatus newState) { + return validTransitions.get(currentState).contains(newState); + } + + public Set getValidTransitions(UserStatus currentState) { + return validTransitions.get(currentState); + } + + public boolean isTerminalState(UserStatus state) { + return validTransitions.get(state).isEmpty(); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/service/UserValidationService.java b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserValidationService.java new file mode 100644 index 0000000..6925cd2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/service/UserValidationService.java @@ -0,0 +1,120 @@ +package com.zenfulcode.commercify.user.domain.service; + +import com.zenfulcode.commercify.user.domain.exception.InvalidUserStateTransitionException; +import com.zenfulcode.commercify.user.domain.exception.UserValidationException; +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class UserValidationService { + private final UserStateFlow userStateFlow; + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[A-Za-z0-9+_.-]+@(.+)$" + ); + private static final Pattern PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\\S+$).{8,}$" + ); + private static final Pattern PHONE_PATTERN = Pattern.compile( + "^\\+?[1-9]\\d{1,14}$" + ); + + public void validateCreateUser(User user) { + List violations = new ArrayList<>(); + + validateBasicUserInfo(user, violations); + validateContactInfo(user, violations); + validatePassword(user.getPassword(), violations); + + if (!violations.isEmpty()) { + throw new UserValidationException(violations); + } + } + + public void validateUpdateUser(User user) { + List violations = new ArrayList<>(); + + validateBasicUserInfo(user, violations); + validateContactInfo(user, violations); + + if (!violations.isEmpty()) { + throw new UserValidationException(violations); + } + } + + public void validateStatusTransition(User user, UserStatus newStatus) { + if (!userStateFlow.canTransition(user.getStatus(), newStatus)) { + throw new InvalidUserStateTransitionException( + user.getId(), + user.getStatus(), + newStatus, + "Invalid status transition" + ); + } + } + + public void validatePasswordChange(String newPassword) { + List violations = new ArrayList<>(); + validatePassword(newPassword, violations); + + if (!violations.isEmpty()) { + throw new UserValidationException(violations); + } + } + + private void validateBasicUserInfo(User user, List violations) { + if (user.getFirstName() == null || user.getFirstName().isBlank()) { + violations.add("First name is required"); + } + if (user.getLastName() == null || user.getLastName().isBlank()) { + violations.add("Last name is required"); + } + } + + private void validateContactInfo(User user, List violations) { + if (user.getEmail() == null || !EMAIL_PATTERN.matcher(user.getEmail()).matches()) { + violations.add("Valid email address is required"); + } + if (user.getPhoneNumber() != null && !PHONE_PATTERN.matcher(user.getPhoneNumber()).matches()) { + violations.add("Invalid phone number format"); + } + } + + private void validatePassword(String password, List violations) { + if (password == null || !PASSWORD_PATTERN.matcher(password).matches()) { + violations.add("Password must be at least 8 characters long and contain at least one " + + "uppercase letter, one lowercase letter, one number, and one special character"); + } + } + + public void validateDeactivation(User user) { + if (user.getStatus() == UserStatus.DEACTIVATED) { + throw new UserValidationException("User is already deactivated"); + } + // Add additional deactivation validation rules here + } + + public void validateAccountDeletion(User user) { + List violations = new ArrayList<>(); + + // Check for active orders + if (user.hasActiveOrders()) { + violations.add("Cannot delete user with active orders"); + } + + // Check for outstanding payments + if (user.hasOutstandingPayments()) { + violations.add("Cannot delete user with outstanding payments"); + } + + if (!violations.isEmpty()) { + throw new UserValidationException(violations); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserDeletionValidation.java b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserDeletionValidation.java new file mode 100644 index 0000000..08fa5e2 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserDeletionValidation.java @@ -0,0 +1,8 @@ +package com.zenfulcode.commercify.user.domain.valueobject; + +import java.util.List; + +public record UserDeletionValidation( + boolean canDelete, + List issues +) {} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserId.java b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserId.java new file mode 100644 index 0000000..1364bdf --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserId.java @@ -0,0 +1,46 @@ +package com.zenfulcode.commercify.user.domain.valueobject; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserId { + private String id; + + private UserId(String id) { + this.id = Objects.requireNonNull(id); + } + + public static UserId generate() { + return new UserId(UUID.randomUUID().toString()); + } + + public static UserId of(String id) { + return new UserId(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserId that = (UserId) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} \ No newline at end of file diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserSpecification.java b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserSpecification.java new file mode 100644 index 0000000..941931b --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserSpecification.java @@ -0,0 +1,34 @@ +package com.zenfulcode.commercify.user.domain.valueobject; + +import com.zenfulcode.commercify.user.domain.model.UserRole; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import lombok.Builder; + +import java.util.Set; + +@Builder +public record UserSpecification( + String firstName, + String lastName, + String email, + String password, + String phone, + UserStatus status, + Set roles +) { + public boolean hasBasicInfoUpdate() { + return firstName != null || lastName != null; + } + + public boolean hasContactInfoUpdate() { + return email != null || phone != null; + } + + public boolean hasCredentialsUpdate() { + return password != null; + } + + public boolean hasRoleUpdate() { + return roles != null; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserUpdateSpec.java b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserUpdateSpec.java new file mode 100644 index 0000000..a586207 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/domain/valueobject/UserUpdateSpec.java @@ -0,0 +1,29 @@ +package com.zenfulcode.commercify.user.domain.valueobject; + +import com.zenfulcode.commercify.user.domain.model.UserRole; + +import java.util.Set; + +public record UserUpdateSpec( + String firstName, + String lastName, + String email, + String phoneNumber, + Set roles +) { + public boolean hasNameUpdate() { + return firstName != null || lastName != null; + } + + public boolean hasEmailUpdate() { + return email != null; + } + + public boolean hasPhoneUpdate() { + return phoneNumber != null; + } + + public boolean hasRolesUpdate() { + return roles != null; + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/infrastructure/config/AdminUserLoader.java b/src/main/java/com/zenfulcode/commercify/user/infrastructure/config/AdminUserLoader.java new file mode 100644 index 0000000..0fac8b3 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/infrastructure/config/AdminUserLoader.java @@ -0,0 +1,53 @@ +package com.zenfulcode.commercify.user.infrastructure.config; + +import com.zenfulcode.commercify.shared.domain.exception.DomainException; +import com.zenfulcode.commercify.user.application.command.CreateUserCommand; +import com.zenfulcode.commercify.user.application.command.UpdateUserStatusCommand; +import com.zenfulcode.commercify.user.application.service.UserApplicationService; +import com.zenfulcode.commercify.user.domain.exception.UserAlreadyExistsException; +import com.zenfulcode.commercify.user.domain.model.UserRole; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class AdminUserLoader { + private final UserApplicationService userApplicationService; + + @Value("${admin.email}") + private String adminEmail; + + @Value("${admin.password}") + private String adminPassword; + + @PostConstruct + public void createAdminUser() { + try { + CreateUserCommand command = new CreateUserCommand( + adminEmail, + "User", + "Admin", + adminPassword, + Set.of(UserRole.ADMIN), + null + ); + + UserId adminId = userApplicationService.createUser(command); + userApplicationService.updateUserStatus(new UpdateUserStatusCommand(adminId, UserStatus.ACTIVE)); + + log.info("Admin user created"); + } catch (UserAlreadyExistsException e) { + log.warn("Admin user already exists"); + } catch (DomainException e) { + log.warn("Failed to create admin user: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/JpaUserRepository.java b/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/JpaUserRepository.java new file mode 100644 index 0000000..854f758 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/JpaUserRepository.java @@ -0,0 +1,59 @@ +package com.zenfulcode.commercify.user.infrastructure.persistence; + +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.repository.UserRepository; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JpaUserRepository implements UserRepository { + private final SpringDataJpaUserRepository repository; + + @Override + public User save(User user) { + return repository.save(user); + } + + @Override + public Optional findById(UserId id) { + return repository.findById(id); + } + + @Override + public Optional findByEmail(String email) { + return repository.findByEmailIgnoreCase(email); + } + + @Override + public boolean existsByEmail(String email) { + return repository.existsByEmailIgnoreCase(email); + } + + @Override + public Page findAll(Pageable pageable) { + return repository.findAll(pageable); + } + + @Override + public Page findByStatus(UserStatus status, Pageable pageable) { + return repository.findByStatus(status, pageable); + } + + @Override + public void delete(User user) { + repository.delete(user); + } + + @Override + public int findNewUsers(Instant startDate, Instant endDate) { + return repository.findNewUsers(startDate, endDate); + } +} diff --git a/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/SpringDataJpaUserRepository.java b/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/SpringDataJpaUserRepository.java new file mode 100644 index 0000000..629be35 --- /dev/null +++ b/src/main/java/com/zenfulcode/commercify/user/infrastructure/persistence/SpringDataJpaUserRepository.java @@ -0,0 +1,32 @@ +package com.zenfulcode.commercify.user.infrastructure.persistence; + +import com.zenfulcode.commercify.user.domain.model.User; +import com.zenfulcode.commercify.user.domain.model.UserStatus; +import com.zenfulcode.commercify.user.domain.valueobject.UserId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; + +@Repository +interface SpringDataJpaUserRepository extends JpaRepository { + Optional findByEmailIgnoreCase(String email); + + boolean existsByEmailIgnoreCase(String email); + + Page findByStatus(UserStatus status, Pageable pageable); + + @Query(""" + SELECT COUNT(u) + FROM User u + WHERE u.createdAt BETWEEN :startDate AND :endDate + """) + int findNewUsers( + @Param("startDate") Instant startDate, + @Param("endDate") Instant endDate); +} \ No newline at end of file diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index cf5731f..56b4d37 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -1,38 +1,38 @@ spring.application.name=commercify-docker server.port=6091 # Database configuration -spring.datasource.url=${SPRING_DATASOURCE_URL} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.url=${DATASOURCE_URL} +spring.datasource.username=${DATASOURCE_USERNAME} +spring.datasource.password=${DATASOURCE_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -# JPA and Hibernate spring.jpa.hibernate.ddl-auto=none -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect -spring.jpa.open-in-view=false -# Liquibase +# Migrations spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml -# Other configurations remain the same -security.jwt.expiration-time=3600000 -security.jwt.secret-key=${JWT_SECRET_KEY} -logging.level.org.springframework=INFO -stripe.secret-test-key=${STRIPE_SECRET_TEST_KEY} -stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET} -mobilepay.client-id=${MOBILEPAY_CLIENT_ID} -mobilepay.merchant-id=${MOBILEPAY_MERCHANT_ID} -mobilepay.client-secret=${MOBILEPAY_CLIENT_SECRET} -mobilepay.subscription-key=${MOBILEPAY_SUBSCRIPTION_KEY} -mobilepay.api-url=${MOBILEPAY_API_URL:https://api.vipps.no} -mobilepay.system-name=${MOBILEPAY_SYSTEM_NAME:commercify} +#spring.liquibase.enabled=false +#The secret key must be an HMAC hash string of 256 bits; +security.jwt.secret=${JWT_SECRET_KEY} +# 1h in millisecond +security.jwt.access-token-expiration=3600000 +security.jwt.refresh-token-expiration=86400000 +# Admin Configuration +admin.email=${ADMIN_EMAIL} +admin.password=${ADMIN_PASSWORD} +admin.order-dashboard=${ADMIN_ORDER_DASHBOARD} +# MobilePay Configuration +integration.payments.mobilepay.client-id=${MOBILEPAY_CLIENT_ID} +integration.payments.mobilepay.merchant-id=${MOBILEPAY_MERCHANT_ID} +integration.payments.mobilepay.client-secret=${MOBILEPAY_CLIENT_SECRET} +integration.payments.mobilepay.subscription-key=${MOBILEPAY_SUBSCRIPTION_KEY} +integration.payments.mobilepay.api-url=${MOBILEPAY_API_URL:https://apitest.vipps.no} +integration.payments.mobilepay.system-name=${MOBILEPAY_SYSTEM_NAME:commercify} +integration.payments.mobilepay.webhook-callback=${MOBILEPAY_WEBHOOK_CALLBACK} # Email Configuration -spring.mail.host=${MAIL_HOST:smtp.gmail.com} +spring.mail.host=${MAIL_HOST} spring.mail.port=${MAIL_PORT:587} spring.mail.username=${MAIL_USERNAME} spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true -admin.order-email=${ORDER_EMAIL_RECEIVER} +frontend.host=${FRONTEND_HOST} # Application Configuration -app.frontend-url=${FRONTEND_URL:http://localhost:3000} -commercify.host=${BACKEND_HOST} -admin.email=${ADMIN_EMAIL} -admin.password=${ADMIN_PASSWORD} \ No newline at end of file +#logging.level.org.springframework.security=debug \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c0bf0db..3ad4c31 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,35 +1,38 @@ spring.application.name=commercify server.port=6091 # Database configuration -spring.datasource.url=${SPRING_DATASOURCE_URL} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.url=${DATASOURCE_URL} +spring.datasource.username=${DATASOURCE_USERNAME} +spring.datasource.password=${DATASOURCE_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=none # Migrations spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml +#spring.liquibase.enabled=false #The secret key must be an HMAC hash string of 256 bits; -security.jwt.secret-key=${JWT_SECRET_KEY} +security.jwt.secret=${JWT_SECRET_KEY} # 1h in millisecond -security.jwt.expiration-time=3600000 -stripe.secret-test-key=${STRIPE_SECRET_TEST_KEY:undefined} -stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:undefined} -mobilepay.client-id=${MOBILEPAY_CLIENT_ID} -mobilepay.merchant-id=${MOBILEPAY_MERCHANT_ID} -mobilepay.client-secret=${MOBILEPAY_CLIENT_SECRET} -mobilepay.subscription-key=${MOBILEPAY_SUBSCRIPTION_KEY} -mobilepay.api-url=${MOBILEPAY_API_URL:https://apitest.vipps.no} -mobilepay.system-name=${MOBILEPAY_SYSTEM_NAME:commercify} +security.jwt.access-token-expiration=3600000 +security.jwt.refresh-token-expiration=86400000 +# Admin Configuration +admin.email=${ADMIN_EMAIL} +admin.password=${ADMIN_PASSWORD} +admin.order-dashboard=${ADMIN_ORDER_DASHBOARD} +# MobilePay Configuration +integration.payments.mobilepay.client-id=${MOBILEPAY_CLIENT_ID} +integration.payments.mobilepay.merchant-id=${MOBILEPAY_MERCHANT_ID} +integration.payments.mobilepay.client-secret=${MOBILEPAY_CLIENT_SECRET} +integration.payments.mobilepay.subscription-key=${MOBILEPAY_SUBSCRIPTION_KEY} +integration.payments.mobilepay.api-url=${MOBILEPAY_API_URL:https://apitest.vipps.no} +integration.payments.mobilepay.system-name=${MOBILEPAY_SYSTEM_NAME:commercify} +integration.payments.mobilepay.webhook-callback=${MOBILEPAY_WEBHOOK_CALLBACK} # Email Configuration -spring.mail.host=${MAIL_HOST:smtp.gmail.com} +spring.mail.host=${MAIL_HOST} spring.mail.port=${MAIL_PORT:587} spring.mail.username=${MAIL_USERNAME} spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true -admin.order-email=${ORDER_EMAIL_RECEIVER} -commercify.host=${BACKEND_HOST} +frontend.host=${FRONTEND_HOST} # Application Configuration -app.frontend-url=${FRONTEND_URL:http://localhost:3000} -admin.email=admin@commercify.app -admin.password=commercifyadmin123! \ No newline at end of file +#logging.level.org.springframework.security=debug \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml index 4077c94..5abd156 100644 --- a/src/main/resources/db/changelog/db.changelog-master.xml +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -3,17 +3,8 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - - - - - - - - - - - - - + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241108185143-changelog.xml b/src/main/resources/db/changelog/migrations/241108185143-changelog.xml deleted file mode 100644 index 4c9bca1..0000000 --- a/src/main/resources/db/changelog/migrations/241108185143-changelog.xml +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241115001827-changelog.xml b/src/main/resources/db/changelog/migrations/241115001827-changelog.xml deleted file mode 100644 index f296de3..0000000 --- a/src/main/resources/db/changelog/migrations/241115001827-changelog.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241115135357-changelog.xml b/src/main/resources/db/changelog/migrations/241115135357-changelog.xml deleted file mode 100644 index 3f94a1f..0000000 --- a/src/main/resources/db/changelog/migrations/241115135357-changelog.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241115144659-changelog.xml b/src/main/resources/db/changelog/migrations/241115144659-changelog.xml deleted file mode 100644 index bf7afd6..0000000 --- a/src/main/resources/db/changelog/migrations/241115144659-changelog.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241121174307-changelog.xml b/src/main/resources/db/changelog/migrations/241121174307-changelog.xml deleted file mode 100644 index 9b08163..0000000 --- a/src/main/resources/db/changelog/migrations/241121174307-changelog.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241127144629-changelog.xml b/src/main/resources/db/changelog/migrations/241127144629-changelog.xml deleted file mode 100644 index 8ffedfa..0000000 --- a/src/main/resources/db/changelog/migrations/241127144629-changelog.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241201215506-changelog.xml b/src/main/resources/db/changelog/migrations/241201215506-changelog.xml deleted file mode 100644 index d814ca7..0000000 --- a/src/main/resources/db/changelog/migrations/241201215506-changelog.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241223151914-changelog.xml b/src/main/resources/db/changelog/migrations/241223151914-changelog.xml deleted file mode 100644 index bdd2e53..0000000 --- a/src/main/resources/db/changelog/migrations/241223151914-changelog.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241230175909-changelog.xml b/src/main/resources/db/changelog/migrations/241230175909-changelog.xml deleted file mode 100644 index 0513578..0000000 --- a/src/main/resources/db/changelog/migrations/241230175909-changelog.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/241230180613-changelog.xml b/src/main/resources/db/changelog/migrations/241230180613-changelog.xml deleted file mode 100644 index d9c5b64..0000000 --- a/src/main/resources/db/changelog/migrations/241230180613-changelog.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/migrations/250112235819-changelog.sql b/src/main/resources/db/changelog/migrations/250112235819-changelog.sql new file mode 100644 index 0000000..0335c28 --- /dev/null +++ b/src/main/resources/db/changelog/migrations/250112235819-changelog.sql @@ -0,0 +1,168 @@ +-- liquibase formatted sql + +-- changeset gkhaavik:1736722698857-1 +CREATE TABLE domain_events +( + event_id VARCHAR(255) NOT NULL, + event_type VARCHAR(255) NOT NULL, + event_data LONGTEXT NOT NULL, + occurred_on datetime NOT NULL, + aggregate_id VARCHAR(255) NULL, + aggregate_type VARCHAR(255) NULL, + CONSTRAINT pk_domain_events PRIMARY KEY (event_id) +); + +-- changeset gkhaavik:1736722698857-2 +CREATE TABLE order_lines +( + quantity INT NOT NULL, + id VARCHAR(255) NOT NULL, + unit_price DECIMAL NULL, + currency VARCHAR(255) NULL, + order_id VARCHAR(255) NOT NULL, + product_id VARCHAR(255) NOT NULL, + product_variant_id VARCHAR(255) NULL, + CONSTRAINT pk_order_lines PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-3 +CREATE TABLE order_shipping_info +( + id BIGINT AUTO_INCREMENT NOT NULL, + customer_first_name VARCHAR(255) NULL, + customer_last_name VARCHAR(255) NULL, + customer_email VARCHAR(255) NULL, + customer_phone VARCHAR(255) NULL, + shipping_street VARCHAR(255) NOT NULL, + shipping_city VARCHAR(255) NOT NULL, + shipping_state VARCHAR(255) NULL, + shipping_zip VARCHAR(255) NOT NULL, + shipping_country VARCHAR(255) NOT NULL, + billing_street VARCHAR(255) NULL, + billing_city VARCHAR(255) NULL, + billing_state VARCHAR(255) NULL, + billing_zip VARCHAR(255) NULL, + billing_country VARCHAR(255) NULL, + CONSTRAINT pk_order_shipping_info PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-4 +CREATE TABLE orders +( + status VARCHAR(255) NOT NULL, + currency VARCHAR(255) NULL, + order_shipping_info_id BIGINT NULL, + created_at datetime NULL, + updated_at datetime NULL, + id VARCHAR(255) NOT NULL, + subtotal DECIMAL NULL, + shipping_cost DECIMAL NULL, + tax DECIMAL NULL, + total_amount DECIMAL NULL, + user_id VARCHAR(255) NULL, + CONSTRAINT pk_orders PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-5 +CREATE TABLE product_variants +( + sku VARCHAR(255) NOT NULL, + stock INT NULL, + image_url VARCHAR(255) NULL, + id VARCHAR(255) NOT NULL, + unit_price DECIMAL NULL, + currency VARCHAR(255) NULL, + product_id VARCHAR(255) NOT NULL, + CONSTRAINT pk_product_variants PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-6 +CREATE TABLE products +( + name VARCHAR(255) NOT NULL, + `description` VARCHAR(255) NULL, + stock INT NOT NULL, + image_url VARCHAR(255) NULL, + active BIT(1) NOT NULL, + created_at datetime NULL, + updated_at datetime NULL, + id VARCHAR(255) NOT NULL, + unit_price DECIMAL NULL, + currency VARCHAR(255) NULL, + category_id VARCHAR(255) NULL, + CONSTRAINT pk_products PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-7 +CREATE TABLE user_roles +( + user_id VARCHAR(255) NOT NULL, + `role` VARCHAR(255) NULL +); + +-- changeset gkhaavik:1736722698857-8 +CREATE TABLE users +( + email VARCHAR(255) NOT NULL, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + phone_number VARCHAR(255) NULL, + status VARCHAR(255) NOT NULL, + created_at datetime NULL, + updated_at datetime NULL, + last_login_at datetime NULL, + id VARCHAR(255) NOT NULL, + CONSTRAINT pk_users PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-9 +CREATE TABLE variant_options +( + id BIGINT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + product_variant_id VARCHAR(255) NOT NULL, + CONSTRAINT pk_variant_options PRIMARY KEY (id) +); + +-- changeset gkhaavik:1736722698857-10 +ALTER TABLE product_variants + ADD CONSTRAINT uc_product_variants_sku UNIQUE (sku); + +-- changeset gkhaavik:1736722698857-11 +ALTER TABLE users + ADD CONSTRAINT uc_users_email UNIQUE (email); + +-- changeset gkhaavik:1736722698857-12 +ALTER TABLE orders + ADD CONSTRAINT FK_ORDERS_ON_ORDER_SHIPPING_INFO FOREIGN KEY (order_shipping_info_id) REFERENCES order_shipping_info (id); + +-- changeset gkhaavik:1736722698857-13 +ALTER TABLE orders + ADD CONSTRAINT FK_ORDERS_ON_USER FOREIGN KEY (user_id) REFERENCES users (id); + +-- changeset gkhaavik:1736722698857-14 +ALTER TABLE order_lines + ADD CONSTRAINT FK_ORDER_LINES_ON_ORDER FOREIGN KEY (order_id) REFERENCES orders (id); + +-- changeset gkhaavik:1736722698857-15 +ALTER TABLE order_lines + ADD CONSTRAINT FK_ORDER_LINES_ON_PRODUCT FOREIGN KEY (product_id) REFERENCES products (id); + +-- changeset gkhaavik:1736722698857-16 +ALTER TABLE order_lines + ADD CONSTRAINT FK_ORDER_LINES_ON_PRODUCT_VARIANT FOREIGN KEY (product_variant_id) REFERENCES product_variants (id); + +-- changeset gkhaavik:1736722698857-17 +ALTER TABLE product_variants + ADD CONSTRAINT FK_PRODUCT_VARIANTS_ON_PRODUCT FOREIGN KEY (product_id) REFERENCES products (id); + +-- changeset gkhaavik:1736722698857-18 +ALTER TABLE variant_options + ADD CONSTRAINT FK_VARIANT_OPTIONS_ON_PRODUCT_VARIANT FOREIGN KEY (product_variant_id) REFERENCES product_variants (id); + +-- changeset gkhaavik:1736722698857-19 +ALTER TABLE user_roles + ADD CONSTRAINT fk_user_roles_on_user FOREIGN KEY (user_id) REFERENCES users (id); + diff --git a/src/main/resources/db/changelog/migrations/250121194314-adding-payments-changelog.sql b/src/main/resources/db/changelog/migrations/250121194314-adding-payments-changelog.sql new file mode 100644 index 0000000..1b40166 --- /dev/null +++ b/src/main/resources/db/changelog/migrations/250121194314-adding-payments-changelog.sql @@ -0,0 +1,40 @@ +-- liquibase formatted sql + +-- changeset gkhaavik:1737484993955-1 +CREATE TABLE payment_attempts +( + payment_id VARCHAR(255) NOT NULL, + attempt_time datetime NULL, + successful BIT(1) NULL, + details VARCHAR(255) NULL +); + +-- changeset gkhaavik:1737484993955-2 +CREATE TABLE payments +( + status VARCHAR(255) NOT NULL, + payment_method VARCHAR(255) NOT NULL, + payment_provider VARCHAR(255) NOT NULL, + provider_reference VARCHAR(255) NULL, + transaction_id VARCHAR(255) NULL, + error_message VARCHAR(255) NULL, + retry_count INT NULL, + max_retries INT NULL, + created_at datetime NULL, + updated_at datetime NULL, + completed_at datetime NULL, + id VARCHAR(255) NOT NULL, + amount DECIMAL NULL, + currency VARCHAR(255) NULL, + order_id VARCHAR(255) NULL, + CONSTRAINT pk_payments PRIMARY KEY (id) +); + +-- changeset gkhaavik:1737484993955-3 +ALTER TABLE payments + ADD CONSTRAINT FK_PAYMENTS_ON_ORDER FOREIGN KEY (order_id) REFERENCES orders (id); + +-- changeset gkhaavik:1737484993955-4 +ALTER TABLE payment_attempts + ADD CONSTRAINT fk_payment_attempts_on_payment FOREIGN KEY (payment_id) REFERENCES payments (id); + diff --git a/src/main/resources/db/changelog/migrations/250126103929-changelog.sql b/src/main/resources/db/changelog/migrations/250126103929-changelog.sql new file mode 100644 index 0000000..94236ce --- /dev/null +++ b/src/main/resources/db/changelog/migrations/250126103929-changelog.sql @@ -0,0 +1,11 @@ +-- liquibase formatted sql + +-- changeset gkhaavik:1737884368941-1 +CREATE TABLE webhook_config +( + provider VARCHAR(255) NOT NULL, + callback_url VARCHAR(255) NOT NULL, + secret VARCHAR(255) NOT NULL, + CONSTRAINT pk_webhook_config PRIMARY KEY (provider) +); + diff --git a/src/main/resources/db/changelog/migrations/250201235523-changelog.sql b/src/main/resources/db/changelog/migrations/250201235523-changelog.sql new file mode 100644 index 0000000..d52ebc2 --- /dev/null +++ b/src/main/resources/db/changelog/migrations/250201235523-changelog.sql @@ -0,0 +1,50 @@ +-- liquibase formatted sql + +-- changeset gkhaavik:1738450523221-1 +ALTER TABLE payments + MODIFY amount DECIMAL; + +-- changeset gkhaavik:1738450523221-3 +ALTER TABLE payments + MODIFY payment_method VARCHAR(255); + +-- changeset gkhaavik:1738450523221-5 +ALTER TABLE payments + MODIFY payment_provider VARCHAR(255); + +-- changeset gkhaavik:1738450523221-6 +ALTER TABLE orders + MODIFY shipping_cost DECIMAL; + +-- changeset gkhaavik:1738450523221-8 +ALTER TABLE orders + MODIFY status VARCHAR(255); + +-- changeset gkhaavik:1738450523221-10 +ALTER TABLE payments + MODIFY status VARCHAR(255); + +-- changeset gkhaavik:1738450523221-11 +ALTER TABLE orders + MODIFY subtotal DECIMAL; + +-- changeset gkhaavik:1738450523221-12 +ALTER TABLE orders + MODIFY tax DECIMAL; + +-- changeset gkhaavik:1738450523221-13 +ALTER TABLE orders + MODIFY total_amount DECIMAL; + +-- changeset gkhaavik:1738450523221-14 +ALTER TABLE order_lines + MODIFY unit_price DECIMAL; + +-- changeset gkhaavik:1738450523221-15 +ALTER TABLE product_variants + MODIFY unit_price DECIMAL; + +-- changeset gkhaavik:1738450523221-16 +ALTER TABLE products + MODIFY unit_price DECIMAL; + diff --git a/src/main/resources/templates/confirmation-email.html b/src/main/resources/templates/confirmation-email.html deleted file mode 100644 index 8d735fb..0000000 --- a/src/main/resources/templates/confirmation-email.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - Confirm your email - - - -

Welcome to Commercify!

-

Thank you for registering. Please click the link below to confirm your email address:

-

- Confirmation Link -

-

This link will expire in 24 hours.

-

If you did not create an account, please ignore this email.

- - \ No newline at end of file diff --git a/src/main/resources/templates/order-confirmation-email.html b/src/main/resources/templates/order-confirmation-email.html deleted file mode 100644 index a5640b0..0000000 --- a/src/main/resources/templates/order-confirmation-email.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - Order Confirmation - - - - -

Order Confirmation - #123

- -

Dear Customer,

- -

Thank you for your order. Here are your order details:

- -
-

Order Status: PENDING

-

Order Date: 01-01-2024

- -
-
-

Shipping Address

-
123 Main St
-
City
-
- State -
-
12345
-
Country
-
- -
-

Billing Address

-
123 Main St
-
City
-
- State -
-
12345
-
Country
-
-
- - - - - - - - - - - - - - - - - - -
ProductQuantityPriceTotal
- Product Name -
Variant Details
-
1$99.99$99.99
- -

Subtotal Amount: $100.00

-

Shipping Cost: $8.00

-

Total Amount: $108.00

-
- -

- Your order has been received and is being processed. We'll notify you when there are any updates. -

- -

- Your order has been confirmed and is being prepared for shipping. -

- -

- Your order has been shipped! You can track your order using the following link: - Tracking Link -

- -

If you have any questions about your order, please contact our customer service.

- - \ No newline at end of file diff --git a/src/main/resources/templates/order/admin-order-notification.html b/src/main/resources/templates/order/admin-order-notification.html new file mode 100644 index 0000000..7282681 --- /dev/null +++ b/src/main/resources/templates/order/admin-order-notification.html @@ -0,0 +1,127 @@ + + + + New Order Received + + + + +
+

New Order Received

+

Order #123

+
+ +
+

Order Information

+

Date: 01-01-2024

+

Status: PENDING

+
+ +
+

Customer Details

+

Name: John Doe

+

Email: john@example.com

+

Phone: +1234567890

+
+ +
+

Order Details

+ + + + + + + + + + + + + + + + + + + +
ProductSKUQuantityPriceTotal
+ Product Name +
Variant Details
+
SKU1231$99.99$99.99
+ +
+ Total Amount: $99.99 +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/order/confirmation-email.html b/src/main/resources/templates/order/confirmation-email.html new file mode 100644 index 0000000..ac6e5ab --- /dev/null +++ b/src/main/resources/templates/order/confirmation-email.html @@ -0,0 +1,118 @@ + + + + Order Confirmation + + + + +
+

Order Confirmation

+

Order #123

+
+ +

Dear Customer,

+ +

Thank you for your order! We're pleased to confirm that we've received your order and it's being processed.

+ +
+

Order Details

+

Order Date: 01-01-2024

+ + + + + + + + + + + + + + + + + + +
ProductQuantityPriceTotal
+ Product Name +
Variant Details
+
1$99.99$99.99
+ +
+ Total Amount: $99.99 +
+
+ +

What happens next?

+
    +
  • We will prepare your order for shipping
  • +
  • You'll receive a shipping confirmation email when your order is on its way
  • +
  • You can track your order status by logging into your account
  • +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/order/shipping-email.html b/src/main/resources/templates/order/shipping-email.html new file mode 100644 index 0000000..45f4e79 --- /dev/null +++ b/src/main/resources/templates/order/shipping-email.html @@ -0,0 +1,119 @@ + + + + Order Shipped + + + + +
+

Your Order Has Been Shipped!

+

Order #123

+
+ +

Dear Customer,

+ +

Great news! Your order is on its way to you.

+ +
+

Shipping Information

+

Shipping Address:

+

123 Main St

+

City, State ZIP

+

Country

+
+ +
+

Order Summary

+ + + + + + + + + + + + + +
ProductQuantity
+ Product Name +
Variant Details
+
1
+
+ +

What to expect:

+
    +
  • Your order is expected to arrive within 3-5 business days
  • +
  • You'll receive a delivery confirmation email once your package has been delivered
  • +
  • If you have any questions about your delivery, please contact our customer service team
  • +
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/order/status-update-email.html b/src/main/resources/templates/order/status-update-email.html new file mode 100644 index 0000000..10a5005 --- /dev/null +++ b/src/main/resources/templates/order/status-update-email.html @@ -0,0 +1,87 @@ + + + + Order Status Update + + + + +
+

Order Status Update

+

Order #123

+
+ +

Dear Customer,

+ +
+

Your order status has been updated to:

+
COMPLETED
+
+ +
+

Order Summary

+

Order Date: 01-01-2024

+

Total Amount: $99.99

+
+ +
+
+

Your order has been successfully completed! We hope you enjoy your purchase.

+

If you have any feedback about our products or service, we'd love to hear from you.

+
+
+

Your order has been cancelled. If you did not request this cancellation, please contact our customer service immediately.

+
+
+

If you have any questions about your order, please don't hesitate to contact our customer service team.

+
+
+ + + + \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapperTest.java b/src/test/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapperTest.java deleted file mode 100644 index 7a8b1ae..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/dto/mapper/OrderMapperTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.zenfulcode.commercify.commercify.dto.mapper; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; -import java.util.LinkedHashSet; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class OrderMapperTest { - - @InjectMocks - private OrderMapper orderMapper; - - private OrderEntity orderEntity; - - @BeforeEach - void setUp() { - Instant now = Instant.now(); - - Set orderLines = new LinkedHashSet<>(); - OrderLineEntity orderLine = OrderLineEntity.builder() - .id(1L) - .productId(1L) - .quantity(2) - .unitPrice(99.99) - .currency("USD") - .build(); - orderLines.add(orderLine); - - orderEntity = OrderEntity.builder() - .id(1L) - .userId(1L) - .orderLines(orderLines) - .status(OrderStatus.PENDING) - .currency("USD") - .subTotal(199.98) - .shippingCost(39.0) - .createdAt(now) - .updatedAt(now) - .build(); - } - - @Test - @DisplayName("Should map OrderEntity to OrderDTO correctly") - void apply_Success() { - OrderDTO result = orderMapper.apply(orderEntity); - - assertNotNull(result); - assertEquals(orderEntity.getId(), result.getId()); - assertEquals(orderEntity.getUserId(), result.getUserId()); - assertEquals(orderEntity.getStatus(), result.getOrderStatus()); - assertEquals(orderEntity.getCurrency(), result.getCurrency()); - assertEquals(orderEntity.getSubTotal(), result.getSubTotal()); - assertEquals(orderEntity.getShippingCost(), result.getShippingCost()); - assertEquals(orderEntity.getCreatedAt(), result.getCreatedAt()); - assertEquals(orderEntity.getUpdatedAt(), result.getUpdatedAt()); - assertEquals(orderEntity.getOrderLines().size(), result.getOrderLinesAmount()); - } - - @Test - @DisplayName("Should handle OrderEntity with null values") - void apply_HandlesNullValues() { - OrderEntity emptyOrder = OrderEntity.builder() - .id(1L) - .build(); - - OrderDTO result = orderMapper.apply(emptyOrder); - - assertNotNull(result); - assertEquals(1L, result.getId()); - assertEquals(0, result.getOrderLinesAmount()); - assertNull(result.getUserId()); - assertNull(result.getCurrency()); - assertEquals(0.0, result.getSubTotal()); - assertEquals(0.0, result.getShippingCost()); - } - - @Test - @DisplayName("Should handle null totalAmount correctly") - void apply_HandlesNullTotalAmount() { - orderEntity.setSubTotal(null); - - OrderDTO result = orderMapper.apply(orderEntity); - - assertNotNull(result); - assertEquals(0.0, result.getSubTotal()); - } - - @Test - @DisplayName("Should handle null orderLines correctly") - void apply_HandlesNullOrderLines() { - orderEntity.setOrderLines(null); - - OrderDTO result = orderMapper.apply(orderEntity); - - assertNotNull(result); - assertEquals(0, result.getOrderLinesAmount()); - } - - @Test - @DisplayName("Should handle getTotalPrice correctly") - void getTotalPrice() { - OrderDTO result = orderMapper.apply(orderEntity); - - assertNotNull(result); - assertEquals(238.98, result.getTotal()); - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/entity/OrderEntityTest.java b/src/test/java/com/zenfulcode/commercify/commercify/entity/OrderEntityTest.java deleted file mode 100644 index 5586a61..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/entity/OrderEntityTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - - -import com.zenfulcode.commercify.commercify.OrderStatus; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.DateTimeException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -class OrderEntityTest { - - private OrderEntity order; - - @BeforeEach - void setUp() { - Set orderLines = new HashSet<>(); - OrderLineEntity orderLine = new OrderLineEntity(); - orderLine.setProductId(1L); - orderLine.setQuantity(2); - orderLine.setUnitPrice(99.99); - orderLine.setCurrency("USD"); - orderLines.add(orderLine); - - order = OrderEntity.builder() - .id(1L) - .userId(1L) - .orderLines(orderLines) - .status(OrderStatus.PENDING) - .currency("USD") - .subTotal(199.98) - .shippingCost(39.0) - .build(); - - // Set up bidirectional relationship - orderLines.forEach(line -> line.setOrder(order)); - } - - @Test - @DisplayName("Should create order with builder pattern") - void testOrderBuilder() { - assertNotNull(order); - assertEquals(1L, order.getId()); - assertEquals(1L, order.getUserId()); - assertEquals(OrderStatus.PENDING, order.getStatus()); - assertEquals("USD", order.getCurrency()); - assertEquals(199.98, order.getSubTotal()); - assertEquals(39.0, order.getShippingCost()); - } - - @Test - @DisplayName("Should manage order lines correctly") - void testOrderLines() { - assertEquals(1, order.getOrderLines().size()); - OrderLineEntity firstLine = order.getOrderLines().stream().findFirst().orElse(null); - assertEquals(2, firstLine.getQuantity()); - assertEquals(99.99, firstLine.getUnitPrice()); - assertEquals(order, firstLine.getOrder()); - } - - @Test - @DisplayName("Should handle empty order lines") - void testEmptyOrderLines() { - order.setOrderLines(new HashSet<>()); - assertTrue(order.getOrderLines().isEmpty()); - } - - @Test - @DisplayName("Should update status correctly") - void testStatusUpdate() { - order.setStatus(OrderStatus.PAID); - assertEquals(OrderStatus.PAID, order.getStatus()); - } - - @Test - @DisplayName("Should handle timestamps correctly") - void testTimestamps() { - LocalDateTime now = LocalDateTime.now(); - - assertThrows(DateTimeException.class, () -> order.setCreatedAt(Instant.from(now))); - assertThrows(DateTimeException.class, () -> order.setUpdatedAt(Instant.from(now))); - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/entity/ProductEntityTest.java b/src/test/java/com/zenfulcode/commercify/commercify/entity/ProductEntityTest.java deleted file mode 100644 index b4f1a21..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/entity/ProductEntityTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.zenfulcode.commercify.commercify.entity; - - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class ProductEntityTest { - - private ProductEntity product; - - @BeforeEach - void setUp() { - product = ProductEntity.builder() - .id(1L) - .name("Test Product") - .description("Test Description") - .stock(10) - .active(true) - .imageUrl("test-image.jpg") - .currency("USD") - .unitPrice(99.99) - .build(); - } - - @Test - @DisplayName("Should create product with builder pattern") - void testProductBuilder() { - assertNotNull(product); - assertEquals("Test Product", product.getName()); - assertEquals("Test Description", product.getDescription()); - assertEquals(10, product.getStock()); - assertTrue(product.getActive()); - assertEquals("USD", product.getCurrency()); - assertEquals(99.99, product.getUnitPrice()); - } - - @Test - @DisplayName("Should update product fields") - void testProductUpdate() { - product.setName("Updated Product"); - product.setStock(5); - product.setUnitPrice(149.99); - - assertEquals("Updated Product", product.getName()); - assertEquals(5, product.getStock()); - assertEquals(149.99, product.getUnitPrice()); - } - - @Test - @DisplayName("Should handle null values") - void testNullValues() { - ProductEntity emptyProduct = new ProductEntity(); - assertNull(emptyProduct.getId()); - assertNull(emptyProduct.getName()); - assertNull(emptyProduct.getStock()); - assertNull(emptyProduct.getActive()); - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/factory/ProductFactoryTest.java b/src/test/java/com/zenfulcode/commercify/commercify/factory/ProductFactoryTest.java deleted file mode 100644 index 152355a..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/factory/ProductFactoryTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.zenfulcode.commercify.commercify.factory; - -import com.zenfulcode.commercify.commercify.api.requests.products.PriceRequest; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.ArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(MockitoExtension.class) -class ProductFactoryTest { - - @InjectMocks - private ProductFactory productFactory; - - private ProductRequest createRequest; - private ProductRequest updateRequest; - private ProductEntity existingProduct; - - @BeforeEach - void setUp() { - PriceRequest createPriceRequest = new PriceRequest("USD", 99.99); - createRequest = new ProductRequest( - "Test Product", - "Test Description", - 10, - "test-image.jpg", - true, - createPriceRequest, - new ArrayList<>() - ); - - PriceRequest priceRequest = new PriceRequest( - "USD", - 99.99 - ); - updateRequest = new ProductRequest( - "Updated Product", - "Updated Description", - 20, - "updated-image.jpg", - true, - priceRequest, - new ArrayList<>() - ); - - existingProduct = ProductEntity.builder() - .id(1L) - .name("Existing Product") - .description("Existing Description") - .stock(5) - .active(true) - .imageUrl("existing-image.jpg") - .currency("USD") - .unitPrice(79.99) - .build(); - } - - @Nested - @DisplayName("Create Product Tests") - class CreateProductTests { - - @Test - @DisplayName("Should create product from request") - void testCreateFromRequest() { - ProductEntity result = productFactory.createFromRequest(createRequest); - - assertNotNull(result); - assertEquals("Test Product", result.getName()); - assertEquals("Test Description", result.getDescription()); - assertEquals(10, result.getStock()); - assertTrue(result.getActive()); - assertEquals("test-image.jpg", result.getImageUrl()); - assertEquals("USD", result.getCurrency()); - assertEquals(99.99, result.getUnitPrice()); - } - - @Test - @DisplayName("Should handle null stock in create request") - void testCreateFromRequestWithNullStock() { - ProductRequest requestWithNullStock = new ProductRequest( - "Test Product", - "Test Description", - null, - "test-image.jpg", - true, - new PriceRequest("USD", 99.99), - new ArrayList<>() - ); - - ProductEntity result = productFactory.createFromRequest(requestWithNullStock); - - assertNotNull(result); - assertEquals(0, result.getStock()); - } - - @Test - @DisplayName("Should preserve unitPrice information in create request") - void testCreateFromRequestPriceInfo() { - PriceRequest priceRequest = new PriceRequest("EUR", 149.99); - ProductRequest requestWithDifferentPrice = new ProductRequest( - "Test Product", - "Test Description", - 10, - "test-image.jpg", - true, - priceRequest, - new ArrayList<>() - ); - - ProductEntity result = productFactory.createFromRequest(requestWithDifferentPrice); - - assertEquals("EUR", result.getCurrency()); - assertEquals(149.99, result.getUnitPrice()); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlowTest.java b/src/test/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlowTest.java deleted file mode 100644 index 05aaa0f..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/flow/OrderStateFlowTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.zenfulcode.commercify.commercify.flow; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.PaymentStatus; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -class StateFlowTest { - - @Nested - @DisplayName("Order State Flow Tests") - class OrderStateFlowTest { - private final OrderStateFlow orderStateFlow = new OrderStateFlow(); - - @Test - @DisplayName("PENDING order can transition to CONFIRMED or CANCELLED") - void testPendingTransitions() { - Set validTransitions = orderStateFlow.getValidTransitions(OrderStatus.PENDING); - assertTrue(orderStateFlow.canTransition(OrderStatus.PENDING, OrderStatus.PAID)); - assertTrue(orderStateFlow.canTransition(OrderStatus.PENDING, OrderStatus.CANCELLED)); - assertEquals(2, validTransitions.size()); - assertTrue(validTransitions.contains(OrderStatus.PAID)); - assertTrue(validTransitions.contains(OrderStatus.CANCELLED)); - } - - @Test - @DisplayName("PAID order can transition to SHIPPED, CANCELLED and COMPLETED") - void testConfirmedTransitions() { - Set validTransitions = orderStateFlow.getValidTransitions(OrderStatus.PAID); - assertTrue(orderStateFlow.canTransition(OrderStatus.PAID, OrderStatus.SHIPPED)); - assertTrue(orderStateFlow.canTransition(OrderStatus.PAID, OrderStatus.CANCELLED)); - assertTrue(orderStateFlow.canTransition(OrderStatus.PAID, OrderStatus.COMPLETED)); - - assertEquals(3, validTransitions.size()); - } - - @Test - @DisplayName("SHIPPED order can transition to COMPLETED or RETURNED") - void testShippedTransitions() { - Set validTransitions = orderStateFlow.getValidTransitions(OrderStatus.SHIPPED); - assertTrue(orderStateFlow.canTransition(OrderStatus.SHIPPED, OrderStatus.COMPLETED)); - assertTrue(orderStateFlow.canTransition(OrderStatus.SHIPPED, OrderStatus.RETURNED)); - assertEquals(2, validTransitions.size()); - } - - @Test - @DisplayName("Terminal states cannot transition") - void testTerminalStates() { - OrderStatus[] terminalStates = { - OrderStatus.COMPLETED, OrderStatus.CANCELLED, OrderStatus.RETURNED, OrderStatus.REFUNDED - }; - - for (OrderStatus status : terminalStates) { - assertTrue(orderStateFlow.isTerminalState(status)); - assertTrue(orderStateFlow.getValidTransitions(status).isEmpty()); - } - } - - @Test - @DisplayName("Invalid transitions are not allowed") - void testInvalidTransitions() { - assertFalse(orderStateFlow.canTransition(OrderStatus.PENDING, OrderStatus.COMPLETED)); - assertFalse(orderStateFlow.canTransition(OrderStatus.PAID, OrderStatus.RETURNED)); - assertFalse(orderStateFlow.canTransition(OrderStatus.SHIPPED, OrderStatus.CANCELLED)); - assertFalse(orderStateFlow.canTransition(OrderStatus.COMPLETED, OrderStatus.SHIPPED)); - } - } - - @Nested - @DisplayName("Payment State Flow Tests") - class PaymentStateFlowTest { - private final PaymentStateFlow paymentStateFlow = new PaymentStateFlow(); - - @Test - @DisplayName("PENDING payment can transition to PAID, FAILED, CANCELLED, or EXPIRED") - void testPendingTransitions() { - Set validTransitions = paymentStateFlow.getValidTransitions(PaymentStatus.PENDING); - assertTrue(paymentStateFlow.canTransition(PaymentStatus.PENDING, PaymentStatus.PAID)); - assertTrue(paymentStateFlow.canTransition(PaymentStatus.PENDING, PaymentStatus.FAILED)); - assertTrue(paymentStateFlow.canTransition(PaymentStatus.PENDING, PaymentStatus.CANCELLED)); - assertTrue(paymentStateFlow.canTransition(PaymentStatus.PENDING, PaymentStatus.EXPIRED)); - assertEquals(4, validTransitions.size()); - } - - @Test - @DisplayName("PAID payment can only transition to REFUNDED") - void testPaidTransitions() { - Set validTransitions = paymentStateFlow.getValidTransitions(PaymentStatus.PAID); - assertTrue(paymentStateFlow.canTransition(PaymentStatus.PAID, PaymentStatus.REFUNDED)); - assertEquals(1, validTransitions.size()); - assertTrue(validTransitions.contains(PaymentStatus.REFUNDED)); - } - - @Test - @DisplayName("Terminal payment states cannot transition") - void testTerminalStates() { - PaymentStatus[] terminalStates = { - PaymentStatus.FAILED, PaymentStatus.CANCELLED, PaymentStatus.REFUNDED, - PaymentStatus.EXPIRED, PaymentStatus.TERMINATED - }; - - for (PaymentStatus status : terminalStates) { - assertTrue(paymentStateFlow.isTerminalState(status)); - assertTrue(paymentStateFlow.getValidTransitions(status).isEmpty()); - } - } - - @Test - @DisplayName("Invalid payment transitions are not allowed") - void testInvalidTransitions() { - assertFalse(paymentStateFlow.canTransition(PaymentStatus.PENDING, PaymentStatus.TERMINATED)); - assertFalse(paymentStateFlow.canTransition(PaymentStatus.PAID, PaymentStatus.CANCELLED)); - assertFalse(paymentStateFlow.canTransition(PaymentStatus.FAILED, PaymentStatus.PAID)); - assertFalse(paymentStateFlow.canTransition(PaymentStatus.REFUNDED, PaymentStatus.PENDING)); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/service/AuthenticationServiceTest.java b/src/test/java/com/zenfulcode/commercify/commercify/service/AuthenticationServiceTest.java deleted file mode 100644 index 933cc05..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/service/AuthenticationServiceTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - - -import com.zenfulcode.commercify.commercify.api.requests.LoginUserRequest; -import com.zenfulcode.commercify.commercify.api.requests.RegisterUserRequest; -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.UserMapper; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.AddressRepository; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import com.zenfulcode.commercify.commercify.service.email.EmailConfirmationService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class AuthenticationServiceTest { - - @Mock - private UserRepository userRepository; - - @Mock - private AddressRepository addressRepository; - @Mock - private AuthenticationManager authenticationManager; - - @Mock - private UserMapper userMapper; - - @Mock - private JwtService jwtService; - - @Mock - private PasswordEncoder passwordEncoder; - - @Mock - private UserManagementService userManagementService; - - @Mock - private EmailConfirmationService emailConfirmationService; - - @InjectMocks - private AuthenticationService authenticationService; - - private RegisterUserRequest registerRequest; - private UserEntity userEntity; - private UserDTO userDTO; - private LoginUserRequest loginRequest; - - @BeforeEach - void setUp() { - AddressDTO shippingAddress = AddressDTO.builder() - .street("123 Test St") - .city("Test City") - .state("Test State") - .zipCode("12345") - .country("Test Country") - .build(); - - registerRequest = new RegisterUserRequest( - "test@example.com", - "password123", - "John", - "Doe", - shippingAddress - ); - - userEntity = UserEntity.builder() - .id(1L) - .email("test@example.com") - .password("encoded_password") - .firstName("John") - .lastName("Doe") - .roles(List.of("USER")) - .emailConfirmed(true) - .build(); - - userDTO = UserDTO.builder() - .id(1L) - .email("test@example.com") - .firstName("John") - .lastName("Doe") - .roles(List.of("USER")) - .defaultAddress(shippingAddress) - .build(); - - loginRequest = new LoginUserRequest("test@example.com", "password123"); - } - - @Test - @DisplayName("Should successfully register a new user") - void registerUser_Success() { - when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); - when(passwordEncoder.encode(anyString())).thenReturn("encoded_password"); - when(userRepository.save(any(UserEntity.class))).thenReturn(userEntity); - when(userMapper.apply(any(UserEntity.class))).thenReturn(userDTO); - - UserDTO result = authenticationService.registerUser(registerRequest); - - assertNotNull(result); - assertEquals("test@example.com", result.getEmail()); - assertEquals("John", result.getFirstName()); - assertEquals("Doe", result.getLastName()); - - verify(userRepository).findByEmail("test@example.com"); - verify(userRepository).save(any(UserEntity.class)); - verify(userMapper).apply(any(UserEntity.class)); - } - - @Test - void registerUser_NoPasswordProvided_ShouldSetDefaultPassword() { - // Arrange - RegisterUserRequest request = new RegisterUserRequest( - "test@example.com", "", "Test", "User", null); - - when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); - when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); - when(userRepository.save(any(UserEntity.class))).thenReturn(userEntity); - when(userMapper.apply(any(UserEntity.class))).thenReturn(userDTO); - - // Act - UserDTO result = authenticationService.registerUser(request); - - // Assert - assertNotNull(result); - assertEquals("test@example.com", result.getEmail()); - verify(passwordEncoder, times(1)).encode(anyString()); - verify(userRepository, times(1)).save(any(UserEntity.class)); - } - - @Test - @DisplayName("Should throw exception when registering with existing email") - void registerUser_ExistingEmail() { - when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(userEntity)); - - assertThrows(RuntimeException.class, () -> authenticationService.registerUser(registerRequest)); - - verify(userRepository).findByEmail("test@example.com"); - verify(userRepository, never()).save(any(UserEntity.class)); - } - - @Test - @DisplayName("Should successfully authenticate user") - void authenticate_Success() { - when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(userEntity)); - when(userMapper.apply(any(UserEntity.class))).thenReturn(userDTO); - when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); - - UserDTO result = authenticationService.authenticate(loginRequest); - - assertNotNull(result); - assertEquals("test@example.com", result.getEmail()); - verify(authenticationManager).authenticate( - new UsernamePasswordAuthenticationToken("test@example.com", "password123") - ); - } - - @Test - @DisplayName("Should get authenticated user details") - void getAuthenticatedUser_Success() { - String jwt = "Bearer valid_jwt_token"; - when(jwtService.extractUsername(anyString())).thenReturn("test@example.com"); - when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(userEntity)); - when(userMapper.apply(any(UserEntity.class))).thenReturn(userDTO); - - UserDTO result = authenticationService.getAuthenticatedUser(jwt); - - assertNotNull(result); - assertEquals("test@example.com", result.getEmail()); - verify(jwtService).extractUsername("valid_jwt_token"); - verify(userRepository).findByEmail("test@example.com"); - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/service/PaymentServiceTest.java b/src/test/java/com/zenfulcode/commercify/commercify/service/PaymentServiceTest.java deleted file mode 100644 index f425f26..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/service/PaymentServiceTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - -import com.zenfulcode.commercify.commercify.PaymentStatus; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO; -import com.zenfulcode.commercify.commercify.entity.PaymentEntity; -import com.zenfulcode.commercify.commercify.repository.PaymentRepository; -import com.zenfulcode.commercify.commercify.service.email.EmailService; -import com.zenfulcode.commercify.commercify.service.order.OrderService; -import jakarta.mail.MessagingException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PaymentServiceTest { - - @Mock - private PaymentRepository paymentRepository; - @Mock - private EmailService emailService; - @Mock - private OrderService orderService; - - private PaymentService paymentService; - - @BeforeEach - void setUp() { - paymentService = new PaymentService(paymentRepository, emailService, orderService); - } - - @Nested - @DisplayName("Payment Status Update Tests") - class PaymentStatusUpdateTests { - - @Test - @DisplayName("Should successfully update payment status and send confirmation email when payment is successful") - void shouldUpdateStatusAndSendEmailOnSuccessfulPayment() throws MessagingException { - // Arrange - Long orderId = 1L; - PaymentEntity payment = PaymentEntity.builder() - .id(1L) - .orderId(orderId) - .status(PaymentStatus.PENDING) - .build(); - - OrderDetailsDTO orderDetails = new OrderDetailsDTO(); - orderDetails.setOrder(new OrderDTO()); - - when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); - when(orderService.getOrderById(orderId)).thenReturn(orderDetails); - - // Act - paymentService.handlePaymentStatusUpdate(orderId, PaymentStatus.PAID); - - // Assert - verify(paymentRepository).save(payment); - verify(orderService).updateOrderStatus(orderId, PaymentStatus.PAID); - verify(emailService).sendOrderConfirmation(orderDetails); - verify(emailService).sendNewOrderNotification(orderDetails); - assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PAID); - } - - @Test - @DisplayName("Should throw exception when payment is not found") - void shouldThrowExceptionWhenPaymentNotFound() { - // Arrange - Long orderId = 1L; - when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); - - // Act & Assert - assertThrows(RuntimeException.class, - () -> paymentService.handlePaymentStatusUpdate(orderId, PaymentStatus.PAID)); - } - -// @Test -// @DisplayName("Should not send email for non-successful payment status updates") -// void shouldNotSendEmailForNonSuccessfulPayments() { -// // Arrange -// Long orderId = 1L; -// PaymentEntity payment = PaymentEntity.builder() -// .id(1L) -// .orderId(orderId) -// .status(PaymentStatus.PENDING) -// .build(); -// -// when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); -// -// // Act -// paymentService.handlePaymentStatusUpdate(orderId, PaymentStatus.CANCELLED); -// -// // Assert -// verify(paymentRepository).save(payment); -// verify(orderService).updateOrderStatus(orderId, PaymentStatus.CANCELLED); -// verify(emailService, never()).sendOrderConfirmation(any()); -// verify(emailService, never()).sendNewOrderNotification(any()); -// assertThat(payment.getStatus()).isEqualTo(PaymentStatus.CANCELLED); -// } - } - - @Nested - @DisplayName("Get Payment Status Tests") - class GetPaymentStatusTests { - - @Test - @DisplayName("Should return correct payment status when payment exists") - void shouldReturnCorrectPaymentStatus() { - // Arrange - Long orderId = 1L; - PaymentEntity payment = PaymentEntity.builder() - .id(1L) - .orderId(orderId) - .status(PaymentStatus.PAID) - .build(); - - when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); - - // Act - PaymentStatus status = paymentService.getPaymentStatus(orderId); - - // Assert - assertThat(status).isEqualTo(PaymentStatus.PAID); - } - - @Test - @DisplayName("Should return NOT_FOUND status when payment doesn't exist") - void shouldReturnNotFoundStatusWhenPaymentDoesntExist() { - // Arrange - Long orderId = 1L; - when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); - - // Act - PaymentStatus status = paymentService.getPaymentStatus(orderId); - - // Assert - assertThat(status).isEqualTo(PaymentStatus.NOT_FOUND); - } - } - - @Test - @DisplayName("Should throw UnsupportedOperationException when attempting to capture payment") - void shouldThrowExceptionWhenCapturingPayment() { - assertThrows(UnsupportedOperationException.class, - () -> paymentService.capturePayment(1L, 100.0, false)); - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/service/UserAddressServiceTest.java b/src/test/java/com/zenfulcode/commercify/commercify/service/UserAddressServiceTest.java deleted file mode 100644 index ce06c65..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/service/UserAddressServiceTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.zenfulcode.commercify.commercify.service; - -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.UserDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.AddressMapper; -import com.zenfulcode.commercify.commercify.dto.mapper.UserMapper; -import com.zenfulcode.commercify.commercify.entity.AddressEntity; -import com.zenfulcode.commercify.commercify.entity.UserEntity; -import com.zenfulcode.commercify.commercify.repository.AddressRepository; -import com.zenfulcode.commercify.commercify.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class UserAddressServiceTest { - - @Mock - private UserRepository userRepository; - - @Mock - private AddressRepository addressRepository; - - @Mock - private UserMapper userMapper; - - @Mock - private AddressMapper addressMapper; - - @InjectMocks - private UserManagementService userManagementService; - - private UserEntity user; - private AddressEntity shippingAddress; - private AddressDTO address; - private UserDTO userDTO; - - @BeforeEach - void setUp() { - user = UserEntity.builder() - .id(1L) - .email("test@example.com") - .firstName("John") - .lastName("Doe") - .build(); - - shippingAddress = AddressEntity.builder() - .id(1L) - .street("123 Ship St") - .city("Ship City") - .state("Ship State") - .zipCode("12345") - .country("Ship Country") - .build(); - - address = AddressDTO.builder() - .street("789 Test St") - .city("Test City") - .state("Test State") - .zipCode("54321") - .country("Test Country") - .build(); - - userDTO = UserDTO.builder() - .id(1L) - .email("test@example.com") - .firstName("John") - .lastName("Doe") - .build(); - } - - @Nested - @DisplayName("Shipping Address Tests") - class ShippingAddressTests { - - @Test - @DisplayName("Should set shipping address successfully") - void setShippingAddress_Success() { - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(any(UserEntity.class))).thenReturn(user); - - userManagementService.setDefaultAddress(1L, address); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserEntity.class); - verify(userRepository).save(userCaptor.capture()); - - AddressEntity savedAddress = userCaptor.getValue().getDefaultAddress(); - assertEquals(address.getStreet(), savedAddress.getStreet()); - assertEquals(address.getCity(), savedAddress.getCity()); - assertEquals(address.getState(), savedAddress.getState()); - assertEquals(address.getZipCode(), savedAddress.getZipCode()); - assertEquals(address.getCountry(), savedAddress.getCountry()); - } - - @Test - @DisplayName("Should remove shipping address successfully") - void removeShippingAddress_Success() { - user.setDefaultAddress(shippingAddress); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(any(UserEntity.class))).thenReturn(user); - when(userMapper.apply(any(UserEntity.class))).thenReturn(userDTO); - - userManagementService.removeDefaultAddress(1L); - - verify(userRepository).save(user); - assertNull(user.getDefaultAddress()); - } - - @Test - @DisplayName("Should throw exception when user not found - shipping") - void setShippingAddress_UserNotFound() { - when(userRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(RuntimeException.class, - () -> userManagementService.setDefaultAddress(1L, address)); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/service/order/OrderServiceTest.java b/src/test/java/com/zenfulcode/commercify/commercify/service/order/OrderServiceTest.java deleted file mode 100644 index bc6b4f3..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/service/order/OrderServiceTest.java +++ /dev/null @@ -1,282 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.order; - -import com.zenfulcode.commercify.commercify.OrderStatus; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderLineRequest; -import com.zenfulcode.commercify.commercify.api.requests.orders.CreateOrderRequest; -import com.zenfulcode.commercify.commercify.dto.AddressDTO; -import com.zenfulcode.commercify.commercify.dto.CustomerDetailsDTO; -import com.zenfulcode.commercify.commercify.dto.OrderDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.OrderMapper; -import com.zenfulcode.commercify.commercify.entity.OrderEntity; -import com.zenfulcode.commercify.commercify.entity.OrderLineEntity; -import com.zenfulcode.commercify.commercify.entity.OrderShippingInfo; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.exception.OrderNotFoundException; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.repository.OrderRepository; -import com.zenfulcode.commercify.commercify.repository.OrderShippingInfoRepository; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import com.zenfulcode.commercify.commercify.service.StockManagementService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; - -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class OrderServiceTest { - - @Mock - private OrderRepository orderRepository; - @Mock - private ProductRepository productRepository; - @Mock - private OrderMapper orderMapper; - @Mock - private OrderValidationService validationService; - @Mock - private OrderCalculationService calculationService; - @Mock - private StockManagementService stockService; - @Mock - private OrderShippingInfoRepository orderShippingInfoRepository; - - @InjectMocks - private OrderService orderService; - - private OrderEntity orderEntity; - private OrderDTO orderDTO; - private CreateOrderRequest createOrderRequest; - private ProductEntity productEntity; - - private OrderShippingInfo orderShippingInfoEntity; - - @BeforeEach - void setUp() { - productEntity = ProductEntity.builder() - .id(1L) - .name("Test Product") - .active(true) - .stock(10) - .unitPrice(99.99) - .currency("USD") - .build(); - - OrderLineEntity orderLine = OrderLineEntity.builder() - .id(1L) - .productId(1L) - .quantity(2) - .unitPrice(99.99) - .currency("USD") - .build(); - - - orderEntity = OrderEntity.builder() - .id(1L) - .userId(1L) - .status(OrderStatus.PENDING) - .currency("USD") - .subTotal(199.98) - .shippingCost(39.0) - .orderLines(Set.of(orderLine)) - .createdAt(Instant.now()) - .build(); - - orderDTO = OrderDTO.builder() - .id(1L) - .userId(1L) - .orderStatus(OrderStatus.PENDING) - .currency("USD") - .subTotal(199.98) - .build(); - - AddressDTO addressDTO = AddressDTO.builder() - .street("Test Street") - .city("Test City") - .state("Test State") - .zipCode("12345") - .country("Test Country") - .build(); - - orderShippingInfoEntity = OrderShippingInfo.builder() - .id(1L) - .customerEmail("test@email.com") - .customerFirstName("Test") - .customerLastName("User") - .customerPhone("1234567890") - .shippingStreet(addressDTO.getStreet()) - .shippingCity(addressDTO.getCity()) - .shippingState(addressDTO.getState()) - .shippingZip(addressDTO.getZipCode()) - .shippingCountry(addressDTO.getCountry()) - .build(); - - CustomerDetailsDTO customerDetailsDTO = CustomerDetailsDTO.builder() - .firstName("Test") - .lastName("User") - .email("test@email.com") - .phone("1234567890") - .build(); - - CreateOrderLineRequest orderLineRequest = new CreateOrderLineRequest(1L, null, 2); - createOrderRequest = new CreateOrderRequest("USD", customerDetailsDTO, List.of(orderLineRequest), 39.0, addressDTO, null); - } - - @Nested - @DisplayName("Create Order Tests") - class CreateOrderTests { - - @Test - @DisplayName("Should create order successfully") - void createOrder_Success() { - when(orderShippingInfoRepository.save(any())).thenReturn(orderShippingInfoEntity); - when(productRepository.findAllById(any())).thenReturn(List.of(productEntity)); - when(calculationService.calculateTotalAmount(any())).thenReturn(199.98); - when(orderRepository.save(any())).thenReturn(orderEntity); - when(orderMapper.apply(any())).thenReturn(orderDTO); - - OrderDTO result = orderService.createOrder(1L, createOrderRequest); - - assertNotNull(result); - assertEquals(orderDTO.getId(), result.getId()); - assertEquals(orderDTO.getSubTotal(), result.getSubTotal()); - assertEquals(orderDTO.getShippingCost(), result.getShippingCost()); - assertEquals(orderDTO.getTotal(), result.getTotal()); - verify(validationService).validateCreateOrderRequest(createOrderRequest); - } - - @Test - @DisplayName("Should throw exception when product not found") - void createOrder_ProductNotFound() { - when(productRepository.findAllById(any())).thenReturn(Collections.emptyList()); - - assertThrows(ProductNotFoundException.class, - () -> orderService.createOrder(1L, createOrderRequest)); - } - } - - @Nested - @DisplayName("Update Order Status Tests") - class UpdateOrderStatusTests { - - @Test - @DisplayName("Should update order status successfully") - void updateOrderStatus_Success() { - when(orderRepository.findById(1L)).thenReturn(Optional.of(orderEntity)); - when(orderRepository.save(any())).thenReturn(orderEntity); - - assertDoesNotThrow(() -> - orderService.updateOrderStatus(1L, OrderStatus.PAID)); - assertEquals(OrderStatus.PAID, orderEntity.getStatus()); - } - - @Test - @DisplayName("Should throw exception when order not found") - void updateOrderStatus_OrderNotFound() { - when(orderRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(OrderNotFoundException.class, - () -> orderService.updateOrderStatus(1L, OrderStatus.PAID)); - } - } - - @Nested - @DisplayName("Get Orders Tests") - class GetOrdersTests { - - @Test - @DisplayName("Should get orders by user ID") - void getOrdersByUserId_Success() { - PageRequest pageRequest = PageRequest.of(0, 10); - Page orderPage = new PageImpl<>(List.of(orderEntity)); - - when(orderRepository.findByUserId(1L, pageRequest)).thenReturn(orderPage); - when(orderMapper.apply(any())).thenReturn(orderDTO); - - Page result = orderService.getOrdersByUserId(1L, pageRequest); - - assertNotNull(result); - assertEquals(1, result.getContent().size()); - verify(orderRepository).findByUserId(1L, pageRequest); - } - - @Test - @DisplayName("Should get all orders") - void getAllOrders_Success() { - PageRequest pageRequest = PageRequest.of(0, 10); - Page orderPage = new PageImpl<>(List.of(orderEntity)); - - when(orderRepository.findAll(pageRequest)).thenReturn(orderPage); - when(orderMapper.apply(any())).thenReturn(orderDTO); - - Page result = orderService.getAllOrders(pageRequest); - - assertNotNull(result); - assertEquals(1, result.getContent().size()); - } - } - - @Nested - @DisplayName("Cancel Order Tests") - class CancelOrderTests { - - @Test - @DisplayName("Should cancel order successfully") - void cancelOrder_Success() { - when(orderRepository.findById(1L)).thenReturn(Optional.of(orderEntity)); - doNothing().when(validationService).validateOrderCancellation(orderEntity); - when(orderRepository.save(any())).thenReturn(orderEntity); - - assertDoesNotThrow(() -> orderService.cancelOrder(1L)); - assertEquals(OrderStatus.CANCELLED, orderEntity.getStatus()); - verify(stockService).restoreStockLevels(orderEntity.getOrderLines()); - } - - @Test - @DisplayName("Should throw exception when cancelling non-existent order") - void cancelOrder_OrderNotFound() { - when(orderRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(OrderNotFoundException.class, - () -> orderService.cancelOrder(1L)); - verify(stockService, never()).restoreStockLevels(any()); - } - } - - @Nested - @DisplayName("Order Ownership Tests") - class OrderOwnershipTests { - - @Test - @DisplayName("Should verify order ownership") - void isOrderOwnedByUser_Success() { - when(orderRepository.existsByIdAndUserId(1L, 1L)).thenReturn(true); - - assertTrue(orderService.isOrderOwnedByUser(1L, 1L)); - } - - @Test - @DisplayName("Should verify order not owned by user") - void isOrderOwnedByUser_NotOwned() { - when(orderRepository.existsByIdAndUserId(1L, 2L)).thenReturn(false); - - assertFalse(orderService.isOrderOwnedByUser(1L, 2L)); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/zenfulcode/commercify/commercify/service/product/ProductServiceTest.java b/src/test/java/com/zenfulcode/commercify/commercify/service/product/ProductServiceTest.java deleted file mode 100644 index 8a584f5..0000000 --- a/src/test/java/com/zenfulcode/commercify/commercify/service/product/ProductServiceTest.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.zenfulcode.commercify.commercify.service.product; - -import com.zenfulcode.commercify.commercify.api.requests.products.PriceRequest; -import com.zenfulcode.commercify.commercify.api.requests.products.ProductRequest; -import com.zenfulcode.commercify.commercify.dto.ProductDTO; -import com.zenfulcode.commercify.commercify.dto.mapper.ProductMapper; -import com.zenfulcode.commercify.commercify.entity.ProductEntity; -import com.zenfulcode.commercify.commercify.exception.ProductNotFoundException; -import com.zenfulcode.commercify.commercify.factory.ProductFactory; -import com.zenfulcode.commercify.commercify.repository.ProductRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ProductServiceTest { - - @Mock - private ProductRepository productRepository; - @Mock - private ProductMapper productMapper; - @Mock - private ProductFactory productFactory; - @Mock - private ProductValidationService validationService; - @Mock - private ProductVariantService variantService; - - @InjectMocks - private ProductService productService; - - private ProductEntity productEntity; - private ProductDTO productDTO; - private ProductRequest productRequest; - - @BeforeEach - void setUp() { - productEntity = ProductEntity.builder() - .id(1L) - .name("Test Product") - .description("Test Description") - .stock(10) - .active(true) - .imageUrl("test-image.jpg") - .currency("USD") - .unitPrice(99.99) - .build(); - - productDTO = ProductDTO.builder() - .id(1L) - .name("Test Product") - .description("Test Description") - .stock(10) - .active(true) - .imageUrl("test-image.jpg") - .currency("USD") - .unitPrice(99.99) - .variants(new ArrayList<>()) - .build(); - - productRequest = new ProductRequest( - "Test Product", - "Test Description", - 10, - "test-image.jpg", - true, - new PriceRequest("USD", 99.99), - new ArrayList<>() - ); - } - - @Nested - @DisplayName("Create Product Tests") - class CreateProductTests { - - @Test - @DisplayName("Should create product successfully") - void createProduct_Success() { - when(productFactory.createFromRequest(any())).thenReturn(productEntity); - when(productRepository.save(any())).thenReturn(productEntity); - when(productMapper.apply(any())).thenReturn(productDTO); - - ProductDTO result = productService.saveProduct(productRequest); - - assertNotNull(result); - assertEquals(productDTO.getName(), result.getName()); - assertEquals(productDTO.getUnitPrice(), result.getUnitPrice()); - verify(validationService).validateProductRequest(productRequest); - } - } - - @Nested - @DisplayName("Retrieve Product Tests") - class RetrieveProductTests { - - @Test - @DisplayName("Should get product by ID") - void getProductById_Success() { - when(productRepository.findById(1L)).thenReturn(Optional.of(productEntity)); - when(productMapper.apply(productEntity)).thenReturn(productDTO); - - ProductDTO result = productService.getProductById(1L); - - assertNotNull(result); - assertEquals(1L, result.getId()); - assertEquals("Test Product", result.getName()); - } - - @Test - @DisplayName("Should throw exception when product not found") - void getProductById_NotFound() { - when(productRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(ProductNotFoundException.class, () -> productService.getProductById(1L)); - } - - @Test - @DisplayName("Should get all active products") - void getActiveProducts_Success() { - PageRequest pageRequest = PageRequest.of(0, 10); - List products = List.of(productEntity); - Page productPage = new PageImpl<>(products); - - when(productRepository.queryAllByActiveTrue(pageRequest)).thenReturn(productPage); - when(productMapper.apply(any())).thenReturn(productDTO); - - Page result = productService.getActiveProducts(pageRequest); - - assertNotNull(result); - assertEquals(1, result.getContent().size()); - verify(productRepository).queryAllByActiveTrue(pageRequest); - } - } - - @Nested - @DisplayName("Update Product Tests") - class UpdateProductTests { - - @Test - @DisplayName("Should update product successfully") - void updateProduct_Success() { - when(productRepository.findById(1L)).thenReturn(Optional.of(productEntity)); - when(productRepository.save(any())).thenReturn(productEntity); - when(productMapper.apply(any())).thenReturn(productDTO); - - var result = productService.updateProduct(1L, productRequest); - - assertNotNull(result); - assertEquals(productDTO, result.getProduct()); - assertTrue(result.getWarnings().isEmpty()); - } - - @Test - @DisplayName("Should throw exception when updating non-existent product") - void updateProduct_NotFound() { - when(productRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(ProductNotFoundException.class, - () -> productService.updateProduct(1L, productRequest)); - } - } - - @Nested - @DisplayName("Delete Product Tests") - class DeleteProductTests { - @Mock - private ProductDeletionService productDeletionService; - - @BeforeEach - void setUp() { - productService = new ProductService( - productRepository, - productMapper, - productFactory, - validationService, - variantService, - productDeletionService - ); - } - - @Test - @DisplayName("Should delete product successfully") - void deleteProduct_Success() { - when(productRepository.findById(1L)).thenReturn(Optional.of(productEntity)); - doNothing().when(productDeletionService).validateAndDelete(productEntity); - - assertDoesNotThrow(() -> productService.deleteProduct(1L)); - verify(productDeletionService).validateAndDelete(productEntity); - } - - @Test - @DisplayName("Should throw exception when deleting non-existent product") - void deleteProduct_NotFound() { - when(productRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThrows(ProductNotFoundException.class, () -> productService.deleteProduct(1L)); - verify(productDeletionService, never()).validateAndDelete(any()); - } - } - - @Nested - @DisplayName("Product Status Tests") - class ProductStatusTests { - - @Test - @DisplayName("Should activate product") - void activateProduct_Success() { - when(productRepository.findById(1L)).thenReturn(Optional.of(productEntity)); - - assertDoesNotThrow(() -> productService.reactivateProduct(1L)); - assertTrue(productEntity.getActive()); - verify(productRepository).save(productEntity); - } - - @Test - @DisplayName("Should deactivate product") - void deactivateProduct_Success() { - when(productRepository.findById(1L)).thenReturn(Optional.of(productEntity)); - - assertDoesNotThrow(() -> productService.deactivateProduct(1L)); - assertFalse(productEntity.getActive()); - verify(productRepository).save(productEntity); - } - } -} \ No newline at end of file