Payment safety fixes #251

Merged
RecentRunner merged 3 commits from payment-fixes into main 2026-04-13 17:52:22 -06:00
21 changed files with 817 additions and 15 deletions

View File

@@ -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<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)
@GetMapping("/me")
public ResponseEntity<UserInfoResponse> getCurrentUser() {

View File

@@ -75,6 +75,14 @@ public class CartController {
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")
@PreAuthorize("isAuthenticated()")
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 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; }
}

View File

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

View File

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

View File

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

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

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 java.util.List;
import java.util.Optional;
@Repository
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);
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()))
.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()

View File

@@ -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,12 +24,16 @@ 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;
@@ -37,13 +43,17 @@ public class CartService {
UserRepository userRepository,
StoreRepository storeRepository,
ProductRepository productRepository,
CouponRepository couponRepository) {
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,10 +87,14 @@ public class CartService {
newCart.setUser(user);
newCart.setStore(store);
newCart.setCartStatus("ACTIVE");
newCart.setPointsApplied(false);
newCart.setPointsDiscountAmount(BigDecimal.ZERO);
return cartRepository.save(newCart);
});
requireNotCheckoutPending(cart);
cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId())
.ifPresentOrElse(
existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()),
@@ -112,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");
}
@@ -136,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);
@@ -147,11 +165,14 @@ 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);
cart.setPointsDiscountAmount(BigDecimal.ZERO);
cart.setTotalAmount(BigDecimal.ZERO);
cart.setCoupon(null);
cart.setPointsApplied(false);
cartRepository.save(cart);
});
}
@@ -162,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"));
@@ -189,12 +212,28 @@ 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"));
requireNotCheckoutPending(cart);
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<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
if (items.isEmpty()) {
throw new BusinessException("Cart is empty");
@@ -219,6 +258,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 +287,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 +317,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<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.setCheckoutPending(false);
cart.setCheckoutAmount(null);
cart.setCheckoutStartedAt(null);
cart.setCheckoutPaymentIntentId(null);
cartRepository.save(cart);
} 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) {
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
@@ -283,10 +424,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<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) {
List<CartItemResponse> itemResponses = cartItemRepository
.findByCartCartId(cart.getCartId())
@@ -309,8 +501,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;
}

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 {
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) {

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