Add checkout snapshot
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.cart.*;
|
||||
import com.petshop.backend.dto.sale.SaleItemRequest;
|
||||
import com.petshop.backend.dto.sale.SaleRequest;
|
||||
import com.petshop.backend.entity.*;
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
@@ -22,28 +24,36 @@ import java.util.List;
|
||||
@Service
|
||||
public class CartService {
|
||||
|
||||
private static final int LOYALTY_POINTS_PER_DOLLAR = 20;
|
||||
|
||||
private final CartRepository cartRepository;
|
||||
private final CartItemRepository cartItemRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final ProductRepository productRepository;
|
||||
private final CouponRepository couponRepository;
|
||||
private final SaleRepository saleRepository;
|
||||
private final SaleService saleService;
|
||||
|
||||
@Value("${stripe.secret-key:}")
|
||||
private String stripeSecretKey;
|
||||
|
||||
public CartService(CartRepository cartRepository,
|
||||
CartItemRepository cartItemRepository,
|
||||
UserRepository userRepository,
|
||||
StoreRepository storeRepository,
|
||||
ProductRepository productRepository,
|
||||
CouponRepository couponRepository) {
|
||||
UserRepository userRepository,
|
||||
StoreRepository storeRepository,
|
||||
ProductRepository productRepository,
|
||||
CouponRepository couponRepository,
|
||||
SaleRepository saleRepository,
|
||||
SaleService saleService) {
|
||||
this.cartRepository = cartRepository;
|
||||
this.cartItemRepository = cartItemRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.productRepository = productRepository;
|
||||
this.couponRepository = couponRepository;
|
||||
this.saleRepository = saleRepository;
|
||||
this.saleService = saleService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -77,6 +87,8 @@ public class CartService {
|
||||
newCart.setUser(user);
|
||||
newCart.setStore(store);
|
||||
newCart.setCartStatus("ACTIVE");
|
||||
newCart.setPointsApplied(false);
|
||||
newCart.setPointsDiscountAmount(BigDecimal.ZERO);
|
||||
|
||||
return cartRepository.save(newCart);
|
||||
});
|
||||
@@ -150,8 +162,10 @@ public class CartService {
|
||||
cartItemRepository.deleteByCartCartId(cart.getCartId());
|
||||
cart.setSubtotalAmount(BigDecimal.ZERO);
|
||||
cart.setDiscountAmount(BigDecimal.ZERO);
|
||||
cart.setPointsDiscountAmount(BigDecimal.ZERO);
|
||||
cart.setTotalAmount(BigDecimal.ZERO);
|
||||
cart.setCoupon(null);
|
||||
cart.setPointsApplied(false);
|
||||
cartRepository.save(cart);
|
||||
});
|
||||
}
|
||||
@@ -189,12 +203,27 @@ public class CartService {
|
||||
return toResponse(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartResponse applyPoints(Long userId, Long storeId, Boolean useLoyaltyPoints) {
|
||||
Cart cart = cartRepository
|
||||
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
|
||||
.orElseThrow(() -> new BusinessException("No active cart found"));
|
||||
|
||||
cart.setPointsApplied(Boolean.TRUE.equals(useLoyaltyPoints));
|
||||
recalculate(cart);
|
||||
return toResponse(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CheckoutResponse checkout(Long userId, Long storeId) {
|
||||
Cart cart = cartRepository
|
||||
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
|
||||
.orElseThrow(() -> new BusinessException("No active cart found"));
|
||||
|
||||
if (Boolean.TRUE.equals(cart.getCheckoutPending())) {
|
||||
throw new BusinessException("Checkout already in progress for this cart");
|
||||
}
|
||||
|
||||
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
|
||||
if (items.isEmpty()) {
|
||||
throw new BusinessException("Cart is empty");
|
||||
@@ -219,6 +248,12 @@ public class CartService {
|
||||
|
||||
PaymentIntent intent = PaymentIntent.create(params);
|
||||
|
||||
cart.setCheckoutPending(true);
|
||||
cart.setCheckoutAmount(cart.getTotalAmount());
|
||||
cart.setCheckoutStartedAt(LocalDateTime.now());
|
||||
cart.setCheckoutPaymentIntentId(intent.getId());
|
||||
cartRepository.save(cart);
|
||||
|
||||
return new CheckoutResponse(
|
||||
cart.getCartId(),
|
||||
intent.getClientSecret(),
|
||||
@@ -242,7 +277,29 @@ public class CartService {
|
||||
throw new BusinessException("Payment has not been completed");
|
||||
}
|
||||
|
||||
Long cartId = Long.parseLong(intent.getMetadata().get("cartId"));
|
||||
String cartIdMetadata = intent.getMetadata() != null ? intent.getMetadata().get("cartId") : null;
|
||||
String userIdMetadata = intent.getMetadata() != null ? intent.getMetadata().get("userId") : null;
|
||||
|
||||
if (cartIdMetadata == null || cartIdMetadata.isBlank()) {
|
||||
throw new BusinessException("Payment metadata is missing cart information");
|
||||
}
|
||||
if (userIdMetadata == null || userIdMetadata.isBlank()) {
|
||||
throw new BusinessException("Payment metadata is missing user information");
|
||||
}
|
||||
|
||||
Long cartId;
|
||||
Long metadataUserId;
|
||||
try {
|
||||
cartId = Long.parseLong(cartIdMetadata);
|
||||
metadataUserId = Long.parseLong(userIdMetadata);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new BusinessException("Payment metadata is invalid");
|
||||
}
|
||||
|
||||
if (!metadataUserId.equals(userId)) {
|
||||
throw new BusinessException("Unauthorized");
|
||||
}
|
||||
|
||||
Cart cart = cartRepository.findById(cartId)
|
||||
.orElseThrow(() -> new BusinessException("Cart not found"));
|
||||
|
||||
@@ -250,7 +307,75 @@ public class CartService {
|
||||
throw new BusinessException("Unauthorized");
|
||||
}
|
||||
|
||||
if (!Boolean.TRUE.equals(cart.getCheckoutPending())) {
|
||||
throw new BusinessException("Cart checkout was not initiated");
|
||||
}
|
||||
|
||||
if (!paymentIntentId.equals(cart.getCheckoutPaymentIntentId())) {
|
||||
throw new BusinessException("Payment intent mismatch");
|
||||
}
|
||||
|
||||
if (cart.getCheckoutAmount() == null) {
|
||||
throw new BusinessException("Checkout amount snapshot is missing");
|
||||
}
|
||||
|
||||
if (!cart.getCartStatus().equals("ACTIVE")) {
|
||||
throw new BusinessException("Cart is not in active state");
|
||||
}
|
||||
|
||||
List<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) {
|
||||
@@ -283,10 +408,61 @@ public class CartService {
|
||||
|
||||
discount = discount.max(BigDecimal.ZERO).min(subtotal);
|
||||
cart.setDiscountAmount(discount);
|
||||
cart.setTotalAmount(subtotal.subtract(discount).max(BigDecimal.ZERO));
|
||||
|
||||
BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO);
|
||||
BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied()));
|
||||
cart.setPointsDiscountAmount(pointsDiscount);
|
||||
cart.setTotalAmount(remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO));
|
||||
cartRepository.save(cart);
|
||||
}
|
||||
|
||||
private BigDecimal recalculateTotalAmount(Cart cart) {
|
||||
List<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 +485,11 @@ public class CartService {
|
||||
response.setItems(itemResponses);
|
||||
response.setSubtotalAmount(cart.getSubtotalAmount());
|
||||
response.setDiscountAmount(cart.getDiscountAmount());
|
||||
response.setPointsDiscountAmount(cart.getPointsDiscountAmount());
|
||||
response.setTotalAmount(cart.getTotalAmount());
|
||||
response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null);
|
||||
response.setPointsApplied(cart.getPointsApplied());
|
||||
response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user