From 141ca34ea0de8c5e37ee5dfc206899893138fc79 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Mon, 13 Apr 2026 17:36:16 -0600 Subject: [PATCH] Add checkout snapshot --- .../java/com/petshop/backend/entity/Cart.java | 66 ++++++ .../petshop/backend/service/CartService.java | 195 +++++++++++++++++- .../V5__password_reset_and_loyalty_points.sql | 25 +++ 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql diff --git a/backend/src/main/java/com/petshop/backend/entity/Cart.java b/backend/src/main/java/com/petshop/backend/entity/Cart.java index ba0566f8..61066af6 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Cart.java +++ b/backend/src/main/java/com/petshop/backend/entity/Cart.java @@ -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; } diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java index d406a44d..74ed8331 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -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 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 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 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 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; } diff --git a/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql b/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql new file mode 100644 index 00000000..9be8b676 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__password_reset_and_loyalty_points.sql @@ -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);