From 141ca34ea0de8c5e37ee5dfc206899893138fc79 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Mon, 13 Apr 2026 17:36:16 -0600 Subject: [PATCH 1/3] Add checkout snapshot --- .../java/com/petshop/backend/entity/Cart.java | 66 ++++++ .../petshop/backend/service/CartService.java | 195 +++++++++++++++++- .../V5__password_reset_and_loyalty_points.sql | 25 +++ 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql diff --git a/backend/src/main/java/com/petshop/backend/entity/Cart.java b/backend/src/main/java/com/petshop/backend/entity/Cart.java index ba0566f8..61066af6 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Cart.java +++ b/backend/src/main/java/com/petshop/backend/entity/Cart.java @@ -40,6 +40,24 @@ public class Cart { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal totalAmount = BigDecimal.ZERO; + @Column(nullable = false) + private Boolean pointsApplied = false; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal pointsDiscountAmount = BigDecimal.ZERO; + + @Column(nullable = false) + private Boolean checkoutPending = false; + + @Column(precision = 10, scale = 2) + private BigDecimal checkoutAmount; + + @Column + private LocalDateTime checkoutStartedAt; + + @Column(length = 255) + private String checkoutPaymentIntentId; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -115,6 +133,54 @@ public class Cart { this.totalAmount = totalAmount; } + public Boolean getPointsApplied() { + return pointsApplied; + } + + public void setPointsApplied(Boolean pointsApplied) { + this.pointsApplied = pointsApplied; + } + + public BigDecimal getPointsDiscountAmount() { + return pointsDiscountAmount; + } + + public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { + this.pointsDiscountAmount = pointsDiscountAmount; + } + + public Boolean getCheckoutPending() { + return checkoutPending; + } + + public void setCheckoutPending(Boolean checkoutPending) { + this.checkoutPending = checkoutPending; + } + + public BigDecimal getCheckoutAmount() { + return checkoutAmount; + } + + public void setCheckoutAmount(BigDecimal checkoutAmount) { + this.checkoutAmount = checkoutAmount; + } + + public LocalDateTime getCheckoutStartedAt() { + return checkoutStartedAt; + } + + public void setCheckoutStartedAt(LocalDateTime checkoutStartedAt) { + this.checkoutStartedAt = checkoutStartedAt; + } + + public String getCheckoutPaymentIntentId() { + return checkoutPaymentIntentId; + } + + public void setCheckoutPaymentIntentId(String checkoutPaymentIntentId) { + this.checkoutPaymentIntentId = checkoutPaymentIntentId; + } + public LocalDateTime getCreatedAt() { return createdAt; } 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 d406a44d..74ed8331 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -1,6 +1,8 @@ package com.petshop.backend.service; import com.petshop.backend.dto.cart.*; +import com.petshop.backend.dto.sale.SaleItemRequest; +import com.petshop.backend.dto.sale.SaleRequest; import com.petshop.backend.entity.*; import com.petshop.backend.exception.BusinessException; import com.petshop.backend.exception.ResourceNotFoundException; @@ -22,28 +24,36 @@ import java.util.List; @Service public class CartService { + private static final int LOYALTY_POINTS_PER_DOLLAR = 20; + private final CartRepository cartRepository; private final CartItemRepository cartItemRepository; private final UserRepository userRepository; private final StoreRepository storeRepository; private final ProductRepository productRepository; private final CouponRepository couponRepository; + private final SaleRepository saleRepository; + private final SaleService saleService; @Value("${stripe.secret-key:}") private String stripeSecretKey; public CartService(CartRepository cartRepository, CartItemRepository cartItemRepository, - UserRepository userRepository, - StoreRepository storeRepository, - ProductRepository productRepository, - CouponRepository couponRepository) { + UserRepository userRepository, + StoreRepository storeRepository, + ProductRepository productRepository, + CouponRepository couponRepository, + SaleRepository saleRepository, + SaleService saleService) { this.cartRepository = cartRepository; this.cartItemRepository = cartItemRepository; this.userRepository = userRepository; this.storeRepository = storeRepository; this.productRepository = productRepository; this.couponRepository = couponRepository; + this.saleRepository = saleRepository; + this.saleService = saleService; } @PostConstruct @@ -77,6 +87,8 @@ public class CartService { newCart.setUser(user); newCart.setStore(store); newCart.setCartStatus("ACTIVE"); + newCart.setPointsApplied(false); + newCart.setPointsDiscountAmount(BigDecimal.ZERO); return cartRepository.save(newCart); }); @@ -150,8 +162,10 @@ public class CartService { cartItemRepository.deleteByCartCartId(cart.getCartId()); cart.setSubtotalAmount(BigDecimal.ZERO); cart.setDiscountAmount(BigDecimal.ZERO); + cart.setPointsDiscountAmount(BigDecimal.ZERO); cart.setTotalAmount(BigDecimal.ZERO); cart.setCoupon(null); + cart.setPointsApplied(false); cartRepository.save(cart); }); } @@ -189,12 +203,27 @@ public class CartService { return toResponse(cart); } + @Transactional + public CartResponse applyPoints(Long userId, Long storeId, Boolean useLoyaltyPoints) { + Cart cart = cartRepository + .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") + .orElseThrow(() -> new BusinessException("No active cart found")); + + cart.setPointsApplied(Boolean.TRUE.equals(useLoyaltyPoints)); + recalculate(cart); + return toResponse(cart); + } + @Transactional public CheckoutResponse checkout(Long userId, Long storeId) { Cart cart = cartRepository .findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .orElseThrow(() -> new BusinessException("No active cart found")); + if (Boolean.TRUE.equals(cart.getCheckoutPending())) { + throw new BusinessException("Checkout already in progress for this cart"); + } + List items = cartItemRepository.findByCartCartId(cart.getCartId()); if (items.isEmpty()) { throw new BusinessException("Cart is empty"); @@ -219,6 +248,12 @@ public class CartService { PaymentIntent intent = PaymentIntent.create(params); + cart.setCheckoutPending(true); + cart.setCheckoutAmount(cart.getTotalAmount()); + cart.setCheckoutStartedAt(LocalDateTime.now()); + cart.setCheckoutPaymentIntentId(intent.getId()); + cartRepository.save(cart); + return new CheckoutResponse( cart.getCartId(), intent.getClientSecret(), @@ -242,7 +277,29 @@ public class CartService { throw new BusinessException("Payment has not been completed"); } - Long cartId = Long.parseLong(intent.getMetadata().get("cartId")); + String cartIdMetadata = intent.getMetadata() != null ? intent.getMetadata().get("cartId") : null; + String userIdMetadata = intent.getMetadata() != null ? intent.getMetadata().get("userId") : null; + + if (cartIdMetadata == null || cartIdMetadata.isBlank()) { + throw new BusinessException("Payment metadata is missing cart information"); + } + if (userIdMetadata == null || userIdMetadata.isBlank()) { + throw new BusinessException("Payment metadata is missing user information"); + } + + Long cartId; + Long metadataUserId; + try { + cartId = Long.parseLong(cartIdMetadata); + metadataUserId = Long.parseLong(userIdMetadata); + } catch (NumberFormatException ex) { + throw new BusinessException("Payment metadata is invalid"); + } + + if (!metadataUserId.equals(userId)) { + throw new BusinessException("Unauthorized"); + } + Cart cart = cartRepository.findById(cartId) .orElseThrow(() -> new BusinessException("Cart not found")); @@ -250,7 +307,75 @@ public class CartService { throw new BusinessException("Unauthorized"); } + if (!Boolean.TRUE.equals(cart.getCheckoutPending())) { + throw new BusinessException("Cart checkout was not initiated"); + } + + if (!paymentIntentId.equals(cart.getCheckoutPaymentIntentId())) { + throw new BusinessException("Payment intent mismatch"); + } + + if (cart.getCheckoutAmount() == null) { + throw new BusinessException("Checkout amount snapshot is missing"); + } + + if (!cart.getCartStatus().equals("ACTIVE")) { + throw new BusinessException("Cart is not in active state"); + } + + List items = cartItemRepository.findByCartCartId(cart.getCartId()); + if (items.isEmpty()) { + throw new BusinessException("Cart items were removed during checkout"); + } + + BigDecimal recalculatedTotal = recalculateTotalAmount(cart); + if (recalculatedTotal.compareTo(cart.getCheckoutAmount()) != 0) { + throw new BusinessException("Cart total changed during checkout"); + } + + long storedAmountInCents = cart.getCheckoutAmount() + .multiply(BigDecimal.valueOf(100)) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + + if (intent.getAmount() != storedAmountInCents) { + throw new BusinessException("Stripe charged amount does not match expected amount"); + } + + if (saleRepository.findByCartCartId(cart.getCartId()).isPresent()) { + cart.setCartStatus("CHECKED_OUT"); + cart.setCheckoutPending(false); + cart.setCheckoutAmount(null); + cart.setCheckoutStartedAt(null); + cart.setCheckoutPaymentIntentId(null); + cartRepository.save(cart); + return; + } + + SaleRequest saleRequest = new SaleRequest(); + saleRequest.setStoreId(cart.getStore().getStoreId()); + saleRequest.setCustomerId(cart.getUser().getId()); + saleRequest.setCartId(cart.getCartId()); + saleRequest.setCouponId(cart.getCoupon() != null ? cart.getCoupon().getCouponId() : null); + saleRequest.setPaymentMethod("Card"); + saleRequest.setChannel("WEBSITE"); + saleRequest.setUseLoyaltyPoints(Boolean.TRUE.equals(cart.getPointsApplied())); + saleRequest.setItems(cartItemRepository.findByCartCartId(cart.getCartId()).stream() + .map(item -> { + SaleItemRequest saleItemRequest = new SaleItemRequest(); + saleItemRequest.setProdId(item.getProduct().getProdId()); + saleItemRequest.setQuantity(item.getQuantity()); + return saleItemRequest; + }) + .toList()); + + saleService.createSale(saleRequest); + cart.setCartStatus("CHECKED_OUT"); + cart.setCheckoutPending(false); + cart.setCheckoutAmount(null); + cart.setCheckoutStartedAt(null); + cart.setCheckoutPaymentIntentId(null); cartRepository.save(cart); } catch (StripeException e) { @@ -274,8 +399,8 @@ public class CartService { if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { discount = subtotal.multiply(coupon.getDiscountValue()) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - } - + } + else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { discount = coupon.getDiscountValue().min(subtotal); } @@ -283,10 +408,61 @@ public class CartService { discount = discount.max(BigDecimal.ZERO).min(subtotal); cart.setDiscountAmount(discount); - cart.setTotalAmount(subtotal.subtract(discount).max(BigDecimal.ZERO)); + + BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO); + BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied())); + cart.setPointsDiscountAmount(pointsDiscount); + cart.setTotalAmount(remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO)); cartRepository.save(cart); } + private BigDecimal recalculateTotalAmount(Cart cart) { + List items = cartItemRepository.findByCartCartId(cart.getCartId()); + + BigDecimal subtotal = items.stream() + .map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal discount = BigDecimal.ZERO; + Coupon coupon = cart.getCoupon(); + + if (coupon != null) { + if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { + discount = subtotal.multiply(coupon.getDiscountValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { + discount = coupon.getDiscountValue().min(subtotal); + } + } + + discount = discount.max(BigDecimal.ZERO).min(subtotal); + + BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO); + BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied())); + + return remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO); + } + + + private BigDecimal calculatePointsDiscount(User user, BigDecimal remainingAmount, boolean pointsApplied) { + if (!pointsApplied || user == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + + int availablePoints = user.getLoyaltyPoints() != null ? user.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 CartResponse toResponse(Cart cart) { List itemResponses = cartItemRepository .findByCartCartId(cart.getCartId()) @@ -309,8 +485,11 @@ public class CartService { response.setItems(itemResponses); response.setSubtotalAmount(cart.getSubtotalAmount()); response.setDiscountAmount(cart.getDiscountAmount()); + response.setPointsDiscountAmount(cart.getPointsDiscountAmount()); response.setTotalAmount(cart.getTotalAmount()); response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null); + response.setPointsApplied(cart.getPointsApplied()); + response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null); return response; } diff --git a/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql b/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql new file mode 100644 index 00000000..9be8b676 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS passwordResetToken ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + userId BIGINT NOT NULL, + tokenHash VARCHAR(64) NOT NULL, + expiresAt DATETIME NOT NULL, + usedAt DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_password_reset_token_hash UNIQUE (tokenHash), + CONSTRAINT fk_password_reset_token_user FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_password_reset_token_user ON passwordResetToken(userId); +CREATE INDEX idx_password_reset_token_expires ON passwordResetToken(expiresAt); + +ALTER TABLE sale + ADD COLUMN pointsUsed INT NOT NULL DEFAULT 0, + ADD COLUMN loyaltyDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00; + +ALTER TABLE cart + ADD COLUMN pointsApplied BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN pointsDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + ADD COLUMN checkoutPending BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN checkoutAmount DECIMAL(10, 2), + ADD COLUMN checkoutStartedAt DATETIME, + ADD COLUMN checkoutPaymentIntentId VARCHAR(255); From a5a1757af7a93d50c4976e516b02870b86e790e8 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Mon, 13 Apr 2026 17:46:03 -0600 Subject: [PATCH 2/3] 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) { From b64b42640661aebab75acc9d93ef28736f9e24e1 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Mon, 13 Apr 2026 17:48:10 -0600 Subject: [PATCH 3/3] Unique sale constraint --- .../resources/db/migration/V6__sale_cart_unique_constraint.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/src/main/resources/db/migration/V6__sale_cart_unique_constraint.sql diff --git a/backend/src/main/resources/db/migration/V6__sale_cart_unique_constraint.sql b/backend/src/main/resources/db/migration/V6__sale_cart_unique_constraint.sql new file mode 100644 index 00000000..edfdf34a --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__sale_cart_unique_constraint.sql @@ -0,0 +1 @@ +ALTER TABLE sale ADD CONSTRAINT uq_sale_cart_id UNIQUE (cartId);