From a5a1757af7a93d50c4976e516b02870b86e790e8 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Mon, 13 Apr 2026 17:46:03 -0600 Subject: [PATCH] Add payment features --- .../backend/controller/AuthController.java | 19 ++- .../backend/controller/CartController.java | 8 ++ .../dto/auth/ForgotPasswordRequest.java | 17 +++ .../dto/auth/ForgotPasswordResponse.java | 20 +++ .../dto/auth/ResetPasswordRequest.java | 30 ++++ .../dto/auth/ResetPasswordResponse.java | 14 ++ .../backend/dto/cart/ApplyPointsRequest.java | 28 ++++ .../backend/dto/cart/CartResponse.java | 12 ++ .../petshop/backend/dto/sale/SaleRequest.java | 16 ++- .../backend/dto/sale/SaleResponse.java | 18 +++ .../backend/entity/PasswordResetToken.java | 102 +++++++++++++ .../java/com/petshop/backend/entity/Sale.java | 22 +++ .../PasswordResetTokenRepository.java | 17 +++ .../backend/repository/SaleRepository.java | 3 + .../backend/security/SecurityConfig.java | 7 +- .../petshop/backend/service/CartService.java | 16 +++ .../backend/service/PasswordResetService.java | 136 ++++++++++++++++++ .../petshop/backend/service/SaleService.java | 60 +++++++- 18 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/cart/ApplyPointsRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/entity/PasswordResetToken.java create mode 100644 backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java create mode 100644 backend/src/main/java/com/petshop/backend/service/PasswordResetService.java diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index c0eff22e..33281560 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -1,11 +1,15 @@ package com.petshop.backend.controller; import com.petshop.backend.dto.auth.AvatarUploadResponse; +import com.petshop.backend.dto.auth.ForgotPasswordRequest; +import com.petshop.backend.dto.auth.ForgotPasswordResponse; import com.petshop.backend.dto.auth.LoginRequest; import com.petshop.backend.dto.auth.LoginResponse; import com.petshop.backend.dto.auth.ProfileUpdateRequest; import com.petshop.backend.dto.auth.RegisterRequest; import com.petshop.backend.dto.auth.RegisterResponse; +import com.petshop.backend.dto.auth.ResetPasswordRequest; +import com.petshop.backend.dto.auth.ResetPasswordResponse; import com.petshop.backend.dto.auth.UserInfoResponse; import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; @@ -13,6 +17,7 @@ import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.AvatarStorageService; +import com.petshop.backend.service.PasswordResetService; import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.PhoneUtils; import jakarta.validation.Valid; @@ -49,14 +54,16 @@ public class AuthController { private final PasswordEncoder passwordEncoder; private final AvatarStorageService avatarStorageService; private final ActivityLogService activityLogService; + private final PasswordResetService passwordResetService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; this.passwordEncoder = passwordEncoder; this.avatarStorageService = avatarStorageService; this.activityLogService = activityLogService; + this.passwordResetService = passwordResetService; } @PostMapping("/register") @@ -153,6 +160,16 @@ public class AuthController { } } + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + return ResponseEntity.ok(passwordResetService.createResetToken(request.getUsernameOrEmail())); + } + + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + return ResponseEntity.ok(passwordResetService.resetPassword(request.getToken(), request.getNewPassword())); + } + @Transactional(readOnly = true) @GetMapping("/me") public ResponseEntity getCurrentUser() { diff --git a/backend/src/main/java/com/petshop/backend/controller/CartController.java b/backend/src/main/java/com/petshop/backend/controller/CartController.java index 49281666..1c2494ff 100644 --- a/backend/src/main/java/com/petshop/backend/controller/CartController.java +++ b/backend/src/main/java/com/petshop/backend/controller/CartController.java @@ -75,6 +75,14 @@ public class CartController { return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode())); } + @PostMapping("/apply-points") + @PreAuthorize("isAuthenticated()") + public ResponseEntity applyPoints(@Valid @RequestBody ApplyPointsRequest request) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + + return ResponseEntity.ok(cartService.applyPoints(userId, request.getStoreId(), request.getUseLoyaltyPoints())); + } + @PostMapping("/checkout") @PreAuthorize("isAuthenticated()") public ResponseEntity checkout(@Valid @RequestBody CheckoutRequest request) { diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordRequest.java b/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordRequest.java new file mode 100644 index 00000000..529581cc --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordRequest.java @@ -0,0 +1,17 @@ +package com.petshop.backend.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +public class ForgotPasswordRequest { + + @NotBlank(message = "Username or email is required") + private String usernameOrEmail; + + public String getUsernameOrEmail() { + return usernameOrEmail; + } + + public void setUsernameOrEmail(String usernameOrEmail) { + this.usernameOrEmail = usernameOrEmail; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordResponse.java new file mode 100644 index 00000000..6062a196 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/ForgotPasswordResponse.java @@ -0,0 +1,20 @@ +package com.petshop.backend.dto.auth; + +public class ForgotPasswordResponse { + + private final String message; + private final String resetToken; + + public ForgotPasswordResponse(String message, String resetToken) { + this.message = message; + this.resetToken = resetToken; + } + + public String getMessage() { + return message; + } + + public String getResetToken() { + return resetToken; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordRequest.java b/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordRequest.java new file mode 100644 index 00000000..3f20f7f7 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordRequest.java @@ -0,0 +1,30 @@ +package com.petshop.backend.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class ResetPasswordRequest { + + @NotBlank(message = "Reset token is required") + private String token; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String newPassword; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordResponse.java new file mode 100644 index 00000000..0edf18a7 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/auth/ResetPasswordResponse.java @@ -0,0 +1,14 @@ +package com.petshop.backend.dto.auth; + +public class ResetPasswordResponse { + + private final String message; + + public ResetPasswordResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/ApplyPointsRequest.java b/backend/src/main/java/com/petshop/backend/dto/cart/ApplyPointsRequest.java new file mode 100644 index 00000000..ac5b5c03 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/cart/ApplyPointsRequest.java @@ -0,0 +1,28 @@ +package com.petshop.backend.dto.cart; + +import jakarta.validation.constraints.NotNull; + +public class ApplyPointsRequest { + + @NotNull(message = "Store ID is required") + private Long storeId; + + @NotNull(message = "useLoyaltyPoints is required") + private Boolean useLoyaltyPoints; + + public Long getStoreId() { + return storeId; + } + + public void setStoreId(Long storeId) { + this.storeId = storeId; + } + + public Boolean getUseLoyaltyPoints() { + return useLoyaltyPoints; + } + + public void setUseLoyaltyPoints(Boolean useLoyaltyPoints) { + this.useLoyaltyPoints = useLoyaltyPoints; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java index 4e2442f6..7dd4268b 100644 --- a/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/cart/CartResponse.java @@ -11,8 +11,11 @@ public class CartResponse { private List items; private BigDecimal subtotalAmount; private BigDecimal discountAmount; + private BigDecimal pointsDiscountAmount; private BigDecimal totalAmount; private String couponCode; + private Boolean pointsApplied; + private Integer availableLoyaltyPoints; public CartResponse() { } @@ -35,9 +38,18 @@ public class CartResponse { public BigDecimal getDiscountAmount() { return discountAmount; } public void setDiscountAmount(BigDecimal discountAmount) { this.discountAmount = discountAmount; } + public BigDecimal getPointsDiscountAmount() { return pointsDiscountAmount; } + public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { this.pointsDiscountAmount = pointsDiscountAmount; } + public BigDecimal getTotalAmount() { return totalAmount; } public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; } public String getCouponCode() { return couponCode; } public void setCouponCode(String couponCode) { this.couponCode = couponCode; } + + public Boolean getPointsApplied() { return pointsApplied; } + public void setPointsApplied(Boolean pointsApplied) { this.pointsApplied = pointsApplied; } + + public Integer getAvailableLoyaltyPoints() { return availableLoyaltyPoints; } + public void setAvailableLoyaltyPoints(Integer availableLoyaltyPoints) { this.availableLoyaltyPoints = availableLoyaltyPoints; } } diff --git a/backend/src/main/java/com/petshop/backend/dto/sale/SaleRequest.java b/backend/src/main/java/com/petshop/backend/dto/sale/SaleRequest.java index 081ab05d..2943ea2a 100644 --- a/backend/src/main/java/com/petshop/backend/dto/sale/SaleRequest.java +++ b/backend/src/main/java/com/petshop/backend/dto/sale/SaleRequest.java @@ -28,6 +28,8 @@ public class SaleRequest { private Long cartId; + private Boolean useLoyaltyPoints = false; + public Long getStoreId() { return storeId; } @@ -100,6 +102,14 @@ public class SaleRequest { this.cartId = cartId; } + public Boolean getUseLoyaltyPoints() { + return useLoyaltyPoints; + } + + public void setUseLoyaltyPoints(Boolean useLoyaltyPoints) { + this.useLoyaltyPoints = useLoyaltyPoints; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -113,12 +123,13 @@ public class SaleRequest { Objects.equals(customerId, that.customerId) && Objects.equals(channel, that.channel) && Objects.equals(couponId, that.couponId) && - Objects.equals(cartId, that.cartId); + Objects.equals(cartId, that.cartId) && + Objects.equals(useLoyaltyPoints, that.useLoyaltyPoints); } @Override public int hashCode() { - return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId); + return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId, useLoyaltyPoints); } @Override @@ -133,6 +144,7 @@ public class SaleRequest { ", channel='" + channel + '\'' + ", couponId=" + couponId + ", cartId=" + cartId + + ", useLoyaltyPoints=" + useLoyaltyPoints + '}'; } } diff --git a/backend/src/main/java/com/petshop/backend/dto/sale/SaleResponse.java b/backend/src/main/java/com/petshop/backend/dto/sale/SaleResponse.java index c1f2357f..4a45c89e 100644 --- a/backend/src/main/java/com/petshop/backend/dto/sale/SaleResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/sale/SaleResponse.java @@ -18,6 +18,8 @@ public class SaleResponse { private BigDecimal subtotalAmount; private BigDecimal couponDiscountAmount; private BigDecimal employeeDiscountAmount; + private BigDecimal loyaltyDiscountAmount; + private Integer pointsUsed; private Integer pointsEarned; private String channel; private Long couponId; @@ -127,6 +129,22 @@ public class SaleResponse { this.employeeDiscountAmount = employeeDiscountAmount; } + public BigDecimal getLoyaltyDiscountAmount() { + return loyaltyDiscountAmount; + } + + public void setLoyaltyDiscountAmount(BigDecimal loyaltyDiscountAmount) { + this.loyaltyDiscountAmount = loyaltyDiscountAmount; + } + + public Integer getPointsUsed() { + return pointsUsed; + } + + public void setPointsUsed(Integer pointsUsed) { + this.pointsUsed = pointsUsed; + } + public Integer getPointsEarned() { return pointsEarned; } diff --git a/backend/src/main/java/com/petshop/backend/entity/PasswordResetToken.java b/backend/src/main/java/com/petshop/backend/entity/PasswordResetToken.java new file mode 100644 index 00000000..bd6c5494 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/entity/PasswordResetToken.java @@ -0,0 +1,102 @@ +package com.petshop.backend.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Table(name = "passwordResetToken") +public class PasswordResetToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", nullable = false) + private User user; + + @Column(nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column + private LocalDateTime usedAt; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getUsedAt() { + return usedAt; + } + + public void setUsedAt(LocalDateTime usedAt) { + this.usedAt = usedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PasswordResetToken that = (PasswordResetToken) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/backend/src/main/java/com/petshop/backend/entity/Sale.java b/backend/src/main/java/com/petshop/backend/entity/Sale.java index 3bf4d8bf..6e9de9ba 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Sale.java +++ b/backend/src/main/java/com/petshop/backend/entity/Sale.java @@ -66,6 +66,12 @@ public class Sale { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal employeeDiscountAmount = BigDecimal.ZERO; + @Column(nullable = false) + private Integer pointsUsed = 0; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal loyaltyDiscountAmount = BigDecimal.ZERO; + @Column(nullable = false) private Integer pointsEarned = 0; @@ -203,6 +209,22 @@ public class Sale { this.employeeDiscountAmount = employeeDiscountAmount; } + public Integer getPointsUsed() { + return pointsUsed; + } + + public void setPointsUsed(Integer pointsUsed) { + this.pointsUsed = pointsUsed; + } + + public BigDecimal getLoyaltyDiscountAmount() { + return loyaltyDiscountAmount; + } + + public void setLoyaltyDiscountAmount(BigDecimal loyaltyDiscountAmount) { + this.loyaltyDiscountAmount = loyaltyDiscountAmount; + } + public Integer getPointsEarned() { return pointsEarned; } diff --git a/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java b/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java new file mode 100644 index 00000000..2d34d381 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java @@ -0,0 +1,17 @@ +package com.petshop.backend.repository; + +import com.petshop.backend.entity.PasswordResetToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PasswordResetTokenRepository extends JpaRepository { + + List findByUser_IdAndUsedAtIsNull(Long userId); + + Optional findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, LocalDateTime now); +} diff --git a/backend/src/main/java/com/petshop/backend/repository/SaleRepository.java b/backend/src/main/java/com/petshop/backend/repository/SaleRepository.java index c648886e..5a7798c2 100644 --- a/backend/src/main/java/com/petshop/backend/repository/SaleRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/SaleRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface SaleRepository extends JpaRepository { @@ -25,4 +26,6 @@ public interface SaleRepository extends JpaRepository { Page searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable); List findByOriginalSaleSaleId(Long originalSaleId); + + Optional findByCartCartId(Long cartId); } diff --git a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java index 1a6cedce..b15d4a96 100644 --- a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -50,7 +50,12 @@ public class SecurityConfig { http.cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll() + .requestMatchers( + "/api/v1/auth/login", + "/api/v1/auth/register", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password" + ).permitAll() .requestMatchers("/api/v1/health").permitAll() .requestMatchers("/ws/chat/**", "/ws/chat-sockjs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java index 74ed8331..f88ce57a 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -93,6 +93,8 @@ public class CartService { return cartRepository.save(newCart); }); + requireNotCheckoutPending(cart); + cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId()) .ifPresentOrElse( existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()), @@ -124,6 +126,8 @@ public class CartService { throw new BusinessException("Cart is not active"); } + requireNotCheckoutPending(item.getCart()); + if (request.getQuantity() < 1) { throw new BusinessException("Quantity must be at least 1"); } @@ -148,6 +152,8 @@ public class CartService { throw new BusinessException("Cart is not active"); } + requireNotCheckoutPending(item.getCart()); + Cart cart = item.getCart(); cartItemRepository.delete(item); recalculate(cart); @@ -159,6 +165,7 @@ public class CartService { public void clearCart(Long userId, Long storeId) { cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .ifPresent(cart -> { + requireNotCheckoutPending(cart); cartItemRepository.deleteByCartCartId(cart.getCartId()); cart.setSubtotalAmount(BigDecimal.ZERO); cart.setDiscountAmount(BigDecimal.ZERO); @@ -176,6 +183,8 @@ public class CartService { .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .orElseThrow(() -> new BusinessException("No active cart found")); + requireNotCheckoutPending(cart); + Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode) .orElseThrow(() -> new BusinessException("Invalid coupon code")); @@ -209,6 +218,7 @@ public class CartService { .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .orElseThrow(() -> new BusinessException("No active cart found")); + requireNotCheckoutPending(cart); cart.setPointsApplied(Boolean.TRUE.equals(useLoyaltyPoints)); recalculate(cart); return toResponse(cart); @@ -383,6 +393,12 @@ public class CartService { } } + private void requireNotCheckoutPending(Cart cart) { + if (Boolean.TRUE.equals(cart.getCheckoutPending())) { + throw new BusinessException("Cannot modify cart while checkout is in progress"); + } + } + private void recalculate(Cart cart) { List items = cartItemRepository.findByCartCartId(cart.getCartId()); diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java new file mode 100644 index 00000000..77a14d18 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java @@ -0,0 +1,136 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.auth.ForgotPasswordResponse; +import com.petshop.backend.dto.auth.ResetPasswordResponse; +import com.petshop.backend.entity.PasswordResetToken; +import com.petshop.backend.entity.User; +import com.petshop.backend.exception.BusinessException; +import com.petshop.backend.repository.PasswordResetTokenRepository; +import com.petshop.backend.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Optional; + +@Service +public class PasswordResetService { + + private static final int RESET_TOKEN_BYTES = 32; + private static final long RESET_TOKEN_MINUTES = 30; + + private final PasswordResetTokenRepository passwordResetTokenRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final SecureRandom secureRandom = new SecureRandom(); + + public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, + UserRepository userRepository, + PasswordEncoder passwordEncoder) { + this.passwordResetTokenRepository = passwordResetTokenRepository; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public ForgotPasswordResponse createResetToken(String usernameOrEmail) { + String normalized = trimToNull(usernameOrEmail); + if (normalized == null) { + throw new BusinessException("Username or email is required"); + } + + Optional user = userRepository.findByEmail(normalized) + .or(() -> userRepository.findByUsername(normalized)); + + if (user.isEmpty() || !Boolean.TRUE.equals(user.get().getActive())) { + return new ForgotPasswordResponse( + "If an account matches that username or email, a reset token has been generated.", + null + ); + } + + User managedUser = user.get(); + LocalDateTime now = LocalDateTime.now(); + invalidateOutstandingTokens(managedUser.getId(), now); + + String rawToken = generateRawToken(); + PasswordResetToken resetToken = new PasswordResetToken(); + resetToken.setUser(managedUser); + resetToken.setTokenHash(hashToken(rawToken)); + resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES)); + passwordResetTokenRepository.save(resetToken); + + return new ForgotPasswordResponse( + "If an account matches that username or email, a reset token has been generated.", + rawToken + ); + } + + @Transactional + public ResetPasswordResponse resetPassword(String rawToken, String newPassword) { + String normalizedToken = trimToNull(rawToken); + if (normalizedToken == null) { + throw new BusinessException("Reset token is required"); + } + + PasswordResetToken token = passwordResetTokenRepository + .findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(hashToken(normalizedToken), LocalDateTime.now()) + .orElseThrow(() -> new BusinessException("Reset token is invalid or has expired")); + + User user = token.getUser(); + if (!Boolean.TRUE.equals(user.getActive())) { + throw new BusinessException("User account is inactive"); + } + + LocalDateTime now = LocalDateTime.now(); + user.setPassword(passwordEncoder.encode(newPassword)); + user.setTokenVersion(user.getTokenVersion() + 1); + userRepository.save(user); + + token.setUsedAt(now); + passwordResetTokenRepository.save(token); + invalidateOutstandingTokens(user.getId(), now); + + return new ResetPasswordResponse("Password reset successfully"); + } + + private void invalidateOutstandingTokens(Long userId, LocalDateTime usedAt) { + for (PasswordResetToken token : passwordResetTokenRepository.findByUser_IdAndUsedAtIsNull(userId)) { + token.setUsedAt(usedAt); + } + } + + private String generateRawToken() { + byte[] bytes = new byte[RESET_TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(hashed.length * 2); + for (byte value : hashed) { + builder.append(String.format("%02x", value)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is not available", ex); + } + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/SaleService.java b/backend/src/main/java/com/petshop/backend/service/SaleService.java index 9d49972f..dd8e73d8 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -22,6 +22,7 @@ import java.util.List; public class SaleService { private static final BigDecimal EMPLOYEE_DISCOUNT_PERCENT = new BigDecimal("0.10"); + private static final int LOYALTY_POINTS_PER_DOLLAR = 20; private final SaleRepository saleRepository; private final ProductRepository productRepository; @@ -56,7 +57,9 @@ public class SaleService { @Transactional public SaleResponse createSale(SaleRequest request) { - User employee = AuthenticationHelper.getAuthenticatedUser(userRepository); + User actor = AuthenticationHelper.getAuthenticatedUser(userRepository); + boolean websiteSale = request.getChannel() != null && request.getChannel().equalsIgnoreCase("WEBSITE"); + User employee = websiteSale ? resolveWebsiteSaleEmployee(request.getStoreId()) : actor; StoreLocation store = storeRepository.findById(request.getStoreId()) .orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getStoreId())); @@ -96,6 +99,11 @@ public class SaleService { sale.setCustomer(customer); } + if (websiteSale && customer == null) { + customer = actor; + sale.setCustomer(customer); + } + if (sale.getIsRefund() && request.getOriginalSaleId() != null) { Sale originalSale = saleRepository.findById(request.getOriginalSaleId()) .orElseThrow(() -> new ResourceNotFoundException("Original sale not found with id: " + request.getOriginalSaleId())); @@ -155,7 +163,15 @@ public class SaleService { subtotalAmount = subtotalAmount.negate(); sale.setSubtotalAmount(subtotalAmount); sale.setTotalAmount(subtotalAmount); + sale.setCouponDiscountAmount(BigDecimal.ZERO); + sale.setEmployeeDiscountAmount(BigDecimal.ZERO); + sale.setLoyaltyDiscountAmount(BigDecimal.ZERO); + sale.setPointsUsed(0); + sale.setPointsEarned(0); } else { + if (request.getItems() == null || request.getItems().isEmpty()) { + throw new BusinessException("At least one item is required"); + } for (var itemRequest : request.getItems()) { Product product = productRepository.findById(itemRequest.getProdId()) .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + itemRequest.getProdId())); @@ -191,12 +207,18 @@ public class SaleService { BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount)); sale.setEmployeeDiscountAmount(employeeDiscount); - BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount); + BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount), Boolean.TRUE.equals(request.getUseLoyaltyPoints())); + sale.setLoyaltyDiscountAmount(loyaltyDiscount); + sale.setPointsUsed(toPointsUsed(loyaltyDiscount)); + + BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount); sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO)); sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue()); if (customer != null) { - customer.setLoyaltyPoints(customer.getLoyaltyPoints() + sale.getPointsEarned()); + int currentPoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0; + int updatedPoints = currentPoints - sale.getPointsUsed() + sale.getPointsEarned(); + customer.setLoyaltyPoints(Math.max(updatedPoints, 0)); userRepository.save(customer); } } @@ -252,6 +274,36 @@ public class SaleService { return BigDecimal.ZERO; } + private BigDecimal calculateLoyaltyDiscount(User customer, BigDecimal remainingAmount, boolean useLoyaltyPoints) { + if (!useLoyaltyPoints || customer == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + int availablePoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0; + int wholeDollars = availablePoints / LOYALTY_POINTS_PER_DOLLAR; + if (wholeDollars <= 0) { + return BigDecimal.ZERO; + } + + BigDecimal maxRedeemable = remainingAmount.setScale(0, RoundingMode.DOWN); + return BigDecimal.valueOf(wholeDollars) + .min(maxRedeemable) + .setScale(2, RoundingMode.HALF_UP); + } + + private int toPointsUsed(BigDecimal loyaltyDiscount) { + if (loyaltyDiscount == null || loyaltyDiscount.compareTo(BigDecimal.ZERO) <= 0) { + return 0; + } + return loyaltyDiscount.setScale(0, RoundingMode.DOWN).intValue() * LOYALTY_POINTS_PER_DOLLAR; + } + + private User resolveWebsiteSaleEmployee(Long storeId) { + return userRepository.findFirstByPrimaryStoreStoreIdAndRoleAndActiveTrueOrderByIdAsc(storeId, User.Role.STAFF) + .or(() -> userRepository.findFirstByRoleAndActiveTrueOrderByIdAsc(User.Role.ADMIN)) + .orElseThrow(() -> new BusinessException("No active employee available for website sale")); + } + private SaleResponse mapToResponse(Sale sale) { SaleResponse response = new SaleResponse(); response.setSaleId(sale.getSaleId()); @@ -273,6 +325,8 @@ public class SaleService { response.setSubtotalAmount(sale.getSubtotalAmount()); response.setCouponDiscountAmount(sale.getCouponDiscountAmount()); response.setEmployeeDiscountAmount(sale.getEmployeeDiscountAmount()); + response.setLoyaltyDiscountAmount(sale.getLoyaltyDiscountAmount()); + response.setPointsUsed(sale.getPointsUsed()); response.setPointsEarned(sale.getPointsEarned()); response.setChannel(sale.getChannel()); if (sale.getCoupon() != null) {