Merge pull request #251 from RecentRunner/payment-fixes

Payment safety fixes
This commit is contained in:
2026-04-13 17:52:21 -06:00
committed by GitHub
21 changed files with 817 additions and 15 deletions

View File

@@ -1,11 +1,15 @@
package com.petshop.backend.controller; package com.petshop.backend.controller;
import com.petshop.backend.dto.auth.AvatarUploadResponse; 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.LoginRequest;
import com.petshop.backend.dto.auth.LoginResponse; import com.petshop.backend.dto.auth.LoginResponse;
import com.petshop.backend.dto.auth.ProfileUpdateRequest; import com.petshop.backend.dto.auth.ProfileUpdateRequest;
import com.petshop.backend.dto.auth.RegisterRequest; import com.petshop.backend.dto.auth.RegisterRequest;
import com.petshop.backend.dto.auth.RegisterResponse; 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.dto.auth.UserInfoResponse;
import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; 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.security.JwtUtil;
import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.PasswordResetService;
import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.PhoneUtils; import com.petshop.backend.util.PhoneUtils;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -49,14 +54,16 @@ public class AuthController {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AvatarStorageService avatarStorageService; private final AvatarStorageService avatarStorageService;
private final ActivityLogService activityLogService; 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.authenticationManager = authenticationManager;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.avatarStorageService = avatarStorageService; this.avatarStorageService = avatarStorageService;
this.activityLogService = activityLogService; this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -153,6 +160,16 @@ public class AuthController {
} }
} }
@PostMapping("/forgot-password")
public ResponseEntity<ForgotPasswordResponse> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(passwordResetService.createResetToken(request.getUsernameOrEmail()));
}
@PostMapping("/reset-password")
public ResponseEntity<ResetPasswordResponse> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
return ResponseEntity.ok(passwordResetService.resetPassword(request.getToken(), request.getNewPassword()));
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<UserInfoResponse> getCurrentUser() { public ResponseEntity<UserInfoResponse> getCurrentUser() {

View File

@@ -75,6 +75,14 @@ public class CartController {
return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode())); return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode()));
} }
@PostMapping("/apply-points")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> applyPoints(@Valid @RequestBody ApplyPointsRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.applyPoints(userId, request.getStoreId(), request.getUseLoyaltyPoints()));
}
@PostMapping("/checkout") @PostMapping("/checkout")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ResponseEntity<CheckoutResponse> checkout(@Valid @RequestBody CheckoutRequest request) { public ResponseEntity<CheckoutResponse> checkout(@Valid @RequestBody CheckoutRequest request) {

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -11,8 +11,11 @@ public class CartResponse {
private List<CartItemResponse> items; private List<CartItemResponse> items;
private BigDecimal subtotalAmount; private BigDecimal subtotalAmount;
private BigDecimal discountAmount; private BigDecimal discountAmount;
private BigDecimal pointsDiscountAmount;
private BigDecimal totalAmount; private BigDecimal totalAmount;
private String couponCode; private String couponCode;
private Boolean pointsApplied;
private Integer availableLoyaltyPoints;
public CartResponse() { public CartResponse() {
} }
@@ -35,9 +38,18 @@ public class CartResponse {
public BigDecimal getDiscountAmount() { return discountAmount; } public BigDecimal getDiscountAmount() { return discountAmount; }
public void setDiscountAmount(BigDecimal discountAmount) { this.discountAmount = 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 BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; } public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public String getCouponCode() { return couponCode; } public String getCouponCode() { return couponCode; }
public void setCouponCode(String couponCode) { this.couponCode = 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; }
} }

View File

@@ -28,6 +28,8 @@ public class SaleRequest {
private Long cartId; private Long cartId;
private Boolean useLoyaltyPoints = false;
public Long getStoreId() { public Long getStoreId() {
return storeId; return storeId;
} }
@@ -100,6 +102,14 @@ public class SaleRequest {
this.cartId = cartId; this.cartId = cartId;
} }
public Boolean getUseLoyaltyPoints() {
return useLoyaltyPoints;
}
public void setUseLoyaltyPoints(Boolean useLoyaltyPoints) {
this.useLoyaltyPoints = useLoyaltyPoints;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
@@ -113,12 +123,13 @@ public class SaleRequest {
Objects.equals(customerId, that.customerId) && Objects.equals(customerId, that.customerId) &&
Objects.equals(channel, that.channel) && Objects.equals(channel, that.channel) &&
Objects.equals(couponId, that.couponId) && Objects.equals(couponId, that.couponId) &&
Objects.equals(cartId, that.cartId); Objects.equals(cartId, that.cartId) &&
Objects.equals(useLoyaltyPoints, that.useLoyaltyPoints);
} }
@Override @Override
public int hashCode() { 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 @Override
@@ -133,6 +144,7 @@ public class SaleRequest {
", channel='" + channel + '\'' + ", channel='" + channel + '\'' +
", couponId=" + couponId + ", couponId=" + couponId +
", cartId=" + cartId + ", cartId=" + cartId +
", useLoyaltyPoints=" + useLoyaltyPoints +
'}'; '}';
} }
} }

View File

@@ -18,6 +18,8 @@ public class SaleResponse {
private BigDecimal subtotalAmount; private BigDecimal subtotalAmount;
private BigDecimal couponDiscountAmount; private BigDecimal couponDiscountAmount;
private BigDecimal employeeDiscountAmount; private BigDecimal employeeDiscountAmount;
private BigDecimal loyaltyDiscountAmount;
private Integer pointsUsed;
private Integer pointsEarned; private Integer pointsEarned;
private String channel; private String channel;
private Long couponId; private Long couponId;
@@ -127,6 +129,22 @@ public class SaleResponse {
this.employeeDiscountAmount = employeeDiscountAmount; 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() { public Integer getPointsEarned() {
return pointsEarned; return pointsEarned;
} }

View File

@@ -40,6 +40,24 @@ public class Cart {
@Column(nullable = false, precision = 10, scale = 2) @Column(nullable = false, precision = 10, scale = 2)
private BigDecimal totalAmount = BigDecimal.ZERO; 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 @CreationTimestamp
@Column(name = "created_at", updatable = false) @Column(name = "created_at", updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -115,6 +133,54 @@ public class Cart {
this.totalAmount = totalAmount; 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() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -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);
}
}

View File

@@ -66,6 +66,12 @@ public class Sale {
@Column(nullable = false, precision = 10, scale = 2) @Column(nullable = false, precision = 10, scale = 2)
private BigDecimal employeeDiscountAmount = BigDecimal.ZERO; 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) @Column(nullable = false)
private Integer pointsEarned = 0; private Integer pointsEarned = 0;
@@ -203,6 +209,22 @@ public class Sale {
this.employeeDiscountAmount = employeeDiscountAmount; 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() { public Integer getPointsEarned() {
return pointsEarned; return pointsEarned;
} }

View File

@@ -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<PasswordResetToken, Long> {
List<PasswordResetToken> findByUser_IdAndUsedAtIsNull(Long userId);
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, LocalDateTime now);
}

View File

@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface SaleRepository extends JpaRepository<Sale, Long> { public interface SaleRepository extends JpaRepository<Sale, Long> {
@@ -25,4 +26,6 @@ public interface SaleRepository extends JpaRepository<Sale, Long> {
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable); Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable);
List<Sale> findByOriginalSaleSaleId(Long originalSaleId); List<Sale> findByOriginalSaleSaleId(Long originalSaleId);
Optional<Sale> findByCartCartId(Long cartId);
} }

View File

@@ -50,7 +50,12 @@ public class SecurityConfig {
http.cors(cors -> cors.configurationSource(corsConfigurationSource())) http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth .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("/api/v1/health").permitAll()
.requestMatchers("/ws/chat/**", "/ws/chat-sockjs/**").permitAll() .requestMatchers("/ws/chat/**", "/ws/chat-sockjs/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()

View File

@@ -1,6 +1,8 @@
package com.petshop.backend.service; package com.petshop.backend.service;
import com.petshop.backend.dto.cart.*; 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.entity.*;
import com.petshop.backend.exception.BusinessException; import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
@@ -22,28 +24,36 @@ import java.util.List;
@Service @Service
public class CartService { public class CartService {
private static final int LOYALTY_POINTS_PER_DOLLAR = 20;
private final CartRepository cartRepository; private final CartRepository cartRepository;
private final CartItemRepository cartItemRepository; private final CartItemRepository cartItemRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final CouponRepository couponRepository; private final CouponRepository couponRepository;
private final SaleRepository saleRepository;
private final SaleService saleService;
@Value("${stripe.secret-key:}") @Value("${stripe.secret-key:}")
private String stripeSecretKey; private String stripeSecretKey;
public CartService(CartRepository cartRepository, public CartService(CartRepository cartRepository,
CartItemRepository cartItemRepository, CartItemRepository cartItemRepository,
UserRepository userRepository, UserRepository userRepository,
StoreRepository storeRepository, StoreRepository storeRepository,
ProductRepository productRepository, ProductRepository productRepository,
CouponRepository couponRepository) { CouponRepository couponRepository,
SaleRepository saleRepository,
SaleService saleService) {
this.cartRepository = cartRepository; this.cartRepository = cartRepository;
this.cartItemRepository = cartItemRepository; this.cartItemRepository = cartItemRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.productRepository = productRepository; this.productRepository = productRepository;
this.couponRepository = couponRepository; this.couponRepository = couponRepository;
this.saleRepository = saleRepository;
this.saleService = saleService;
} }
@PostConstruct @PostConstruct
@@ -77,10 +87,14 @@ public class CartService {
newCart.setUser(user); newCart.setUser(user);
newCart.setStore(store); newCart.setStore(store);
newCart.setCartStatus("ACTIVE"); newCart.setCartStatus("ACTIVE");
newCart.setPointsApplied(false);
newCart.setPointsDiscountAmount(BigDecimal.ZERO);
return cartRepository.save(newCart); return cartRepository.save(newCart);
}); });
requireNotCheckoutPending(cart);
cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId()) cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId())
.ifPresentOrElse( .ifPresentOrElse(
existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()), existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()),
@@ -112,6 +126,8 @@ public class CartService {
throw new BusinessException("Cart is not active"); throw new BusinessException("Cart is not active");
} }
requireNotCheckoutPending(item.getCart());
if (request.getQuantity() < 1) { if (request.getQuantity() < 1) {
throw new BusinessException("Quantity must be at least 1"); throw new BusinessException("Quantity must be at least 1");
} }
@@ -136,6 +152,8 @@ public class CartService {
throw new BusinessException("Cart is not active"); throw new BusinessException("Cart is not active");
} }
requireNotCheckoutPending(item.getCart());
Cart cart = item.getCart(); Cart cart = item.getCart();
cartItemRepository.delete(item); cartItemRepository.delete(item);
recalculate(cart); recalculate(cart);
@@ -147,11 +165,14 @@ public class CartService {
public void clearCart(Long userId, Long storeId) { public void clearCart(Long userId, Long storeId) {
cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.ifPresent(cart -> { .ifPresent(cart -> {
requireNotCheckoutPending(cart);
cartItemRepository.deleteByCartCartId(cart.getCartId()); cartItemRepository.deleteByCartCartId(cart.getCartId());
cart.setSubtotalAmount(BigDecimal.ZERO); cart.setSubtotalAmount(BigDecimal.ZERO);
cart.setDiscountAmount(BigDecimal.ZERO); cart.setDiscountAmount(BigDecimal.ZERO);
cart.setPointsDiscountAmount(BigDecimal.ZERO);
cart.setTotalAmount(BigDecimal.ZERO); cart.setTotalAmount(BigDecimal.ZERO);
cart.setCoupon(null); cart.setCoupon(null);
cart.setPointsApplied(false);
cartRepository.save(cart); cartRepository.save(cart);
}); });
} }
@@ -162,6 +183,8 @@ public class CartService {
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.orElseThrow(() -> new BusinessException("No active cart found")); .orElseThrow(() -> new BusinessException("No active cart found"));
requireNotCheckoutPending(cart);
Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode) Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode)
.orElseThrow(() -> new BusinessException("Invalid coupon code")); .orElseThrow(() -> new BusinessException("Invalid coupon code"));
@@ -189,12 +212,28 @@ public class CartService {
return toResponse(cart); 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"));
requireNotCheckoutPending(cart);
cart.setPointsApplied(Boolean.TRUE.equals(useLoyaltyPoints));
recalculate(cart);
return toResponse(cart);
}
@Transactional @Transactional
public CheckoutResponse checkout(Long userId, Long storeId) { public CheckoutResponse checkout(Long userId, Long storeId) {
Cart cart = cartRepository Cart cart = cartRepository
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE") .findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.orElseThrow(() -> new BusinessException("No active cart found")); .orElseThrow(() -> new BusinessException("No active cart found"));
if (Boolean.TRUE.equals(cart.getCheckoutPending())) {
throw new BusinessException("Checkout already in progress for this cart");
}
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId()); List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
if (items.isEmpty()) { if (items.isEmpty()) {
throw new BusinessException("Cart is empty"); throw new BusinessException("Cart is empty");
@@ -219,6 +258,12 @@ public class CartService {
PaymentIntent intent = PaymentIntent.create(params); 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( return new CheckoutResponse(
cart.getCartId(), cart.getCartId(),
intent.getClientSecret(), intent.getClientSecret(),
@@ -242,7 +287,29 @@ public class CartService {
throw new BusinessException("Payment has not been completed"); 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) Cart cart = cartRepository.findById(cartId)
.orElseThrow(() -> new BusinessException("Cart not found")); .orElseThrow(() -> new BusinessException("Cart not found"));
@@ -250,7 +317,75 @@ public class CartService {
throw new BusinessException("Unauthorized"); 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<CartItem> 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.setCartStatus("CHECKED_OUT");
cart.setCheckoutPending(false);
cart.setCheckoutAmount(null);
cart.setCheckoutStartedAt(null);
cart.setCheckoutPaymentIntentId(null);
cartRepository.save(cart); cartRepository.save(cart);
} catch (StripeException e) { } catch (StripeException e) {
@@ -258,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) { private void recalculate(Cart cart) {
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId()); List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
@@ -274,8 +415,8 @@ public class CartService {
if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) { if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue()) discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
} }
else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) { else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal); discount = coupon.getDiscountValue().min(subtotal);
} }
@@ -283,10 +424,61 @@ public class CartService {
discount = discount.max(BigDecimal.ZERO).min(subtotal); discount = discount.max(BigDecimal.ZERO).min(subtotal);
cart.setDiscountAmount(discount); 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); cartRepository.save(cart);
} }
private BigDecimal recalculateTotalAmount(Cart cart) {
List<CartItem> 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) { private CartResponse toResponse(Cart cart) {
List<CartItemResponse> itemResponses = cartItemRepository List<CartItemResponse> itemResponses = cartItemRepository
.findByCartCartId(cart.getCartId()) .findByCartCartId(cart.getCartId())
@@ -309,8 +501,11 @@ public class CartService {
response.setItems(itemResponses); response.setItems(itemResponses);
response.setSubtotalAmount(cart.getSubtotalAmount()); response.setSubtotalAmount(cart.getSubtotalAmount());
response.setDiscountAmount(cart.getDiscountAmount()); response.setDiscountAmount(cart.getDiscountAmount());
response.setPointsDiscountAmount(cart.getPointsDiscountAmount());
response.setTotalAmount(cart.getTotalAmount()); response.setTotalAmount(cart.getTotalAmount());
response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null); response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null);
response.setPointsApplied(cart.getPointsApplied());
response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null);
return response; return response;
} }

View File

@@ -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> 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;
}
}

View File

@@ -22,6 +22,7 @@ import java.util.List;
public class SaleService { public class SaleService {
private static final BigDecimal EMPLOYEE_DISCOUNT_PERCENT = new BigDecimal("0.10"); 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 SaleRepository saleRepository;
private final ProductRepository productRepository; private final ProductRepository productRepository;
@@ -56,7 +57,9 @@ public class SaleService {
@Transactional @Transactional
public SaleResponse createSale(SaleRequest request) { 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()) StoreLocation store = storeRepository.findById(request.getStoreId())
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getStoreId())); .orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getStoreId()));
@@ -96,6 +99,11 @@ public class SaleService {
sale.setCustomer(customer); sale.setCustomer(customer);
} }
if (websiteSale && customer == null) {
customer = actor;
sale.setCustomer(customer);
}
if (sale.getIsRefund() && request.getOriginalSaleId() != null) { if (sale.getIsRefund() && request.getOriginalSaleId() != null) {
Sale originalSale = saleRepository.findById(request.getOriginalSaleId()) Sale originalSale = saleRepository.findById(request.getOriginalSaleId())
.orElseThrow(() -> new ResourceNotFoundException("Original sale not found with id: " + request.getOriginalSaleId())); .orElseThrow(() -> new ResourceNotFoundException("Original sale not found with id: " + request.getOriginalSaleId()));
@@ -155,7 +163,15 @@ public class SaleService {
subtotalAmount = subtotalAmount.negate(); subtotalAmount = subtotalAmount.negate();
sale.setSubtotalAmount(subtotalAmount); sale.setSubtotalAmount(subtotalAmount);
sale.setTotalAmount(subtotalAmount); sale.setTotalAmount(subtotalAmount);
sale.setCouponDiscountAmount(BigDecimal.ZERO);
sale.setEmployeeDiscountAmount(BigDecimal.ZERO);
sale.setLoyaltyDiscountAmount(BigDecimal.ZERO);
sale.setPointsUsed(0);
sale.setPointsEarned(0);
} else { } else {
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("At least one item is required");
}
for (var itemRequest : request.getItems()) { for (var itemRequest : request.getItems()) {
Product product = productRepository.findById(itemRequest.getProdId()) Product product = productRepository.findById(itemRequest.getProdId())
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + 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)); BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
sale.setEmployeeDiscountAmount(employeeDiscount); 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.setTotalAmount(finalTotal.max(BigDecimal.ZERO));
sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue()); sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue());
if (customer != null) { 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); userRepository.save(customer);
} }
} }
@@ -252,6 +274,36 @@ public class SaleService {
return BigDecimal.ZERO; 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) { private SaleResponse mapToResponse(Sale sale) {
SaleResponse response = new SaleResponse(); SaleResponse response = new SaleResponse();
response.setSaleId(sale.getSaleId()); response.setSaleId(sale.getSaleId());
@@ -273,6 +325,8 @@ public class SaleService {
response.setSubtotalAmount(sale.getSubtotalAmount()); response.setSubtotalAmount(sale.getSubtotalAmount());
response.setCouponDiscountAmount(sale.getCouponDiscountAmount()); response.setCouponDiscountAmount(sale.getCouponDiscountAmount());
response.setEmployeeDiscountAmount(sale.getEmployeeDiscountAmount()); response.setEmployeeDiscountAmount(sale.getEmployeeDiscountAmount());
response.setLoyaltyDiscountAmount(sale.getLoyaltyDiscountAmount());
response.setPointsUsed(sale.getPointsUsed());
response.setPointsEarned(sale.getPointsEarned()); response.setPointsEarned(sale.getPointsEarned());
response.setChannel(sale.getChannel()); response.setChannel(sale.getChannel());
if (sale.getCoupon() != null) { if (sale.getCoupon() != null) {

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE sale ADD CONSTRAINT uq_sale_cart_id UNIQUE (cartId);