Add payment features

This commit is contained in:
2026-04-13 17:46:03 -06:00
parent 1577ed41cd
commit a221f2a91b
18 changed files with 538 additions and 7 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

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

@@ -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<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());

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