Add checkout snapshot

This commit is contained in:
2026-04-13 17:36:16 -06:00
parent c145b3e552
commit 1577ed41cd
3 changed files with 278 additions and 8 deletions

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

@@ -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) {
@@ -274,8 +399,8 @@ public class CartService {
if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
}
else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal);
}
@@ -283,10 +408,61 @@ public class CartService {
discount = discount.max(BigDecimal.ZERO).min(subtotal);
cart.setDiscountAmount(discount);
cart.setTotalAmount(subtotal.subtract(discount).max(BigDecimal.ZERO));
BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO);
BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied()));
cart.setPointsDiscountAmount(pointsDiscount);
cart.setTotalAmount(remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO));
cartRepository.save(cart);
}
private BigDecimal recalculateTotalAmount(Cart cart) {
List<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;
}

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