Stripe Payment

This commit is contained in:
augmentedpotato
2026-04-09 22:27:03 -06:00
parent a7ba0fb4b4
commit 1010d57b79
26 changed files with 2022 additions and 33 deletions

View File

@@ -0,0 +1,84 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.cart.*;
import com.petshop.backend.service.CartService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/cart")
public class CartController {
private final CartService cartService;
public CartController(CartService cartService) {
this.cartService = cartService;
}
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> getActiveCart(@RequestParam Long storeId) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
CartResponse cart = cartService.getActiveCart(userId, storeId);
if (cart == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(cart);
}
@PostMapping("/add")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> addItem(@Valid @RequestBody AddToCartRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.addItem(userId, request));
}
@PutMapping("/update")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> updateItem(@Valid @RequestBody UpdateCartItemRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.updateItem(userId, request));
}
@DeleteMapping("/remove/{cartItemId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> removeItem(@PathVariable Long cartItemId) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.removeItem(userId, cartItemId));
}
@DeleteMapping("/clear")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> clearCart(@RequestParam Long storeId) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
cartService.clearCart(userId, storeId);
return ResponseEntity.noContent().build();
}
@PostMapping("/apply-coupon")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> applyCoupon(
@RequestParam Long storeId,
@Valid @RequestBody ApplyCouponRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode()));
}
@PostMapping("/checkout")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CheckoutResponse> checkout(@Valid @RequestBody CheckoutRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.checkout(userId, request.getStoreId(), request.getPaymentMethodId()));}
}

View File

@@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/stores")
@PreAuthorize("hasRole('ADMIN')")
public class StoreController {
private final StoreService storeService;
@@ -24,6 +23,7 @@ public class StoreController {
}
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Page<StoreResponse>> getAllStores(
@RequestParam(required = false) String q,
Pageable pageable) {
@@ -31,6 +31,7 @@ public class StoreController {
}
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<StoreResponse> getStoreById(@PathVariable Long id) {
return ResponseEntity.ok(storeService.getStoreById(id));
}

View File

@@ -0,0 +1,43 @@
package com.petshop.backend.dto.cart;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class AddToCartRequest {
@NotNull
private Long prodId;
@NotNull
private Long storeId;
@NotNull
@Min(1)
private Integer quantity;
public Long getProdId() {
return prodId;
}
public void setProdId(Long prodId) {
this.prodId = prodId;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,17 @@
package com.petshop.backend.dto.cart;
import jakarta.validation.constraints.NotBlank;
public class ApplyCouponRequest {
@NotBlank
private String couponCode;
public String getCouponCode() {
return couponCode;
}
public void setCouponCode(String couponCode) {
this.couponCode = couponCode;
}
}

View File

@@ -0,0 +1,90 @@
package com.petshop.backend.dto.cart;
import java.math.BigDecimal;
public class CartItemResponse {
private Long cartItemId;
private Long prodId;
private String prodName;
private String imageUrl;
private BigDecimal unitPrice;
private Integer quantity;
private BigDecimal lineTotal;
public CartItemResponse() {
}
public CartItemResponse(Long cartItemId, Long prodId, String prodName, String imageUrl,
BigDecimal unitPrice, Integer quantity, BigDecimal lineTotal) {
this.cartItemId = cartItemId;
this.prodId = prodId;
this.prodName = prodName;
this.imageUrl = imageUrl;
this.unitPrice = unitPrice;
this.quantity = quantity;
this.lineTotal = lineTotal;
}
public Long getCartItemId() {
return cartItemId;
}
public void setCartItemId(Long cartItemId) {
this.cartItemId = cartItemId;
}
public Long getProdId() {
return prodId;
}
public void setProdId(Long prodId) {
this.prodId = prodId;
}
public String getProdName() {
return prodName;
}
public void setProdName(String prodName) {
this.prodName = prodName;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
public void setUnitPrice(BigDecimal unitPrice) {
this.unitPrice = unitPrice;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public BigDecimal getLineTotal() {
return lineTotal;
}
public void setLineTotal(BigDecimal lineTotal) {
this.lineTotal = lineTotal;
}
}

View File

@@ -0,0 +1,43 @@
package com.petshop.backend.dto.cart;
import java.math.BigDecimal;
import java.util.List;
public class CartResponse {
private Long cartId;
private Long storeId;
private String cartStatus;
private List<CartItemResponse> items;
private BigDecimal subtotalAmount;
private BigDecimal discountAmount;
private BigDecimal totalAmount;
private String couponCode;
public CartResponse() {
}
public Long getCartId() { return cartId; }
public void setCartId(Long cartId) { this.cartId = cartId; }
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getCartStatus() { return cartStatus; }
public void setCartStatus(String cartStatus) { this.cartStatus = cartStatus; }
public List<CartItemResponse> getItems() { return items; }
public void setItems(List<CartItemResponse> items) { this.items = items; }
public BigDecimal getSubtotalAmount() { return subtotalAmount; }
public void setSubtotalAmount(BigDecimal subtotalAmount) { this.subtotalAmount = subtotalAmount; }
public BigDecimal getDiscountAmount() { return discountAmount; }
public void setDiscountAmount(BigDecimal discountAmount) { this.discountAmount = discountAmount; }
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; }
}

View File

@@ -0,0 +1,24 @@
package com.petshop.backend.dto.cart;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class CheckoutRequest {
@NotNull
private Long storeId;
@NotBlank
private String paymentMethodId;
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getPaymentMethodId() {
return paymentMethodId;
}
public void setPaymentMethodId(String paymentMethodId) {
this.paymentMethodId = paymentMethodId;
}
}

View File

@@ -0,0 +1,57 @@
package com.petshop.backend.dto.cart;
import java.math.BigDecimal;
public class CheckoutResponse {
private Long cartId;
private String clientSecret;
private BigDecimal totalAmount;
private String status;
public CheckoutResponse() {
}
public CheckoutResponse(Long cartId, String clientSecret, BigDecimal totalAmount, String status) {
this.cartId = cartId;
this.clientSecret = clientSecret;
this.totalAmount = totalAmount;
this.status = status;
}
public Long getCartId() {
return cartId;
}
public void setCartId(Long cartId) {
this.cartId = cartId;
}
public String getClientSecret() {
return clientSecret;
}
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,32 @@
package com.petshop.backend.dto.cart;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class UpdateCartItemRequest {
@NotNull
private Long cartItemId;
@NotNull
@Min(1)
private Integer quantity;
public Long getCartItemId() {
return cartItemId;
}
public void setCartItemId(Long cartItemId) {
this.cartItemId = cartItemId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -5,9 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
List<CartItem> findByCartCartId(Long cartId);
Optional<CartItem> findByCartCartIdAndProductProdId(Long cartId, Long prodId);
void deleteByCartCartId(Long cartId);
}

View File

@@ -2,6 +2,8 @@ package com.petshop.backend.repository;
import com.petshop.backend.entity.Cart;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -13,4 +15,9 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
List<Cart> findByUserId(Long userId);
Optional<Cart> findByUserIdAndCartStatus(Long userId, String cartStatus);
@Query("SELECT c FROM Cart c WHERE c.user.id = :userId AND c.store.storeId = :storeId AND c.cartStatus = :status")
Optional<Cart> findActiveCartByUserAndStore(@Param("userId") Long userId,
@Param("storeId") Long storeId,
@Param("status") String status);
}

View File

@@ -10,4 +10,6 @@ import java.util.Optional;
public interface CouponRepository extends JpaRepository<Coupon, Long> {
Optional<Coupon> findByCouponCode(String couponCode);
Optional<Coupon> findByCouponCodeIgnoreCase(String couponCode);
}

View File

@@ -0,0 +1,301 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.cart.*;
import com.petshop.backend.entity.*;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.*;
import com.stripe.Stripe;
import com.stripe.exception.StripeException;
import com.stripe.model.PaymentIntent;
import com.stripe.param.PaymentIntentCreateParams;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class CartService {
private final CartRepository cartRepository;
private final CartItemRepository cartItemRepository;
private final UserRepository userRepository;
private final StoreRepository storeRepository;
private final ProductRepository productRepository;
private final CouponRepository couponRepository;
@Value("${stripe.secret-key:}")
private String stripeSecretKey;
public CartService(CartRepository cartRepository,
CartItemRepository cartItemRepository,
UserRepository userRepository,
StoreRepository storeRepository,
ProductRepository productRepository,
CouponRepository couponRepository) {
this.cartRepository = cartRepository;
this.cartItemRepository = cartItemRepository;
this.userRepository = userRepository;
this.storeRepository = storeRepository;
this.productRepository = productRepository;
this.couponRepository = couponRepository;
}
@PostConstruct
public void initStripe() {
if (stripeSecretKey != null && !stripeSecretKey.isBlank()) {
Stripe.apiKey = stripeSecretKey;
}
}
public CartResponse getActiveCart(Long userId, Long storeId) {
return cartRepository
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.map(this::toResponse)
.orElse(null);
}
@Transactional
public CartResponse addItem(Long userId, AddToCartRequest request) {
User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found"));
StoreLocation store = storeRepository.findById(request.getStoreId()).orElseThrow(() -> new ResourceNotFoundException("Store not found"));
Product product = productRepository.findById(request.getProdId()).orElseThrow(() -> new ResourceNotFoundException("Product not found"));
if (request.getQuantity() < 1) {
throw new BusinessException("Quantity must be at least 1");
}
Cart cart = cartRepository
.findActiveCartByUserAndStore(userId, request.getStoreId(), "ACTIVE")
.orElseGet(() -> {
Cart newCart = new Cart();
newCart.setUser(user);
newCart.setStore(store);
newCart.setCartStatus("ACTIVE");
return cartRepository.save(newCart);
});
cartItemRepository.findByCartCartIdAndProductProdId(cart.getCartId(), product.getProdId())
.ifPresentOrElse(
existing -> existing.setQuantity(existing.getQuantity() + request.getQuantity()),
() -> {
CartItem item = new CartItem();
item.setCart(cart);
item.setProduct(product);
item.setQuantity(request.getQuantity());
item.setUnitPrice(product.getProdPrice());
cartItemRepository.save(item);
}
);
recalculate(cart);
return toResponse(cart);
}
@Transactional
public CartResponse updateItem(Long userId, UpdateCartItemRequest request) {
CartItem item = cartItemRepository.findById(request.getCartItemId())
.orElseThrow(() -> new ResourceNotFoundException("Cart item not found"));
if (!item.getCart().getUser().getId().equals(userId)) {
throw new BusinessException("Access denied");
}
if (!item.getCart().getCartStatus().equals("ACTIVE")) {
throw new BusinessException("Cart is not active");
}
if (request.getQuantity() < 1) {
throw new BusinessException("Quantity must be at least 1");
}
item.setQuantity(request.getQuantity());
cartItemRepository.save(item);
recalculate(item.getCart());
return toResponse(item.getCart());
}
@Transactional
public CartResponse removeItem(Long userId, Long cartItemId) {
CartItem item = cartItemRepository.findById(cartItemId)
.orElseThrow(() -> new ResourceNotFoundException("Cart item not found"));
if (!item.getCart().getUser().getId().equals(userId)) {
throw new BusinessException("Access denied");
}
if (!item.getCart().getCartStatus().equals("ACTIVE")) {
throw new BusinessException("Cart is not active");
}
Cart cart = item.getCart();
cartItemRepository.delete(item);
recalculate(cart);
return toResponse(cart);
}
@Transactional
public void clearCart(Long userId, Long storeId) {
cartRepository.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.ifPresent(cart -> {
cartItemRepository.deleteByCartCartId(cart.getCartId());
cart.setSubtotalAmount(BigDecimal.ZERO);
cart.setDiscountAmount(BigDecimal.ZERO);
cart.setTotalAmount(BigDecimal.ZERO);
cart.setCoupon(null);
cartRepository.save(cart);
});
}
@Transactional
public CartResponse applyCoupon(Long userId, Long storeId, String couponCode) {
Cart cart = cartRepository
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.orElseThrow(() -> new BusinessException("No active cart found"));
Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode)
.orElseThrow(() -> new BusinessException("Invalid coupon code"));
LocalDateTime now = LocalDateTime.now();
if (!coupon.getActive()) {
throw new BusinessException("Coupon is no longer active");
}
if (coupon.getStartsAt() != null && now.isBefore(coupon.getStartsAt())) {
throw new BusinessException("Coupon is not yet valid");
}
if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) {
throw new BusinessException("Coupon has expired");
}
if (coupon.getMinOrderAmount() != null && cart.getSubtotalAmount().compareTo(coupon.getMinOrderAmount()) < 0) {
throw new BusinessException("Minimum order amount of $" + coupon.getMinOrderAmount() + " required");
}
cart.setCoupon(coupon);
recalculate(cart);
return toResponse(cart);
}
@Transactional
public CheckoutResponse checkout(Long userId, Long storeId, String paymentMethodId) {
Cart cart = cartRepository
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.orElseThrow(() -> new BusinessException("No active cart found"));
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
if (items.isEmpty()) {
throw new BusinessException("Cart is empty");
}
long amountInCents = cart.getTotalAmount()
.multiply(BigDecimal.valueOf(100))
.setScale(0, RoundingMode.HALF_UP)
.longValue();
if (amountInCents < 50) {
throw new BusinessException("Order total is too low to process payment");
}
try {
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(amountInCents)
.setCurrency("usd")
.setPaymentMethod(paymentMethodId)
.setConfirm(true)
.setReturnUrl("http://localhost:3000/cart/confirmation")
.putMetadata("cartId", String.valueOf(cart.getCartId()))
.putMetadata("userId", String.valueOf(userId))
.build();
PaymentIntent intent = PaymentIntent.create(params);
if ("succeeded".equals(intent.getStatus())
|| "requires_action".equals(intent.getStatus())) {
cart.setCartStatus("CHECKED_OUT");
cartRepository.save(cart);
}
return new CheckoutResponse(
cart.getCartId(),
intent.getClientSecret(),
cart.getTotalAmount(),
intent.getStatus()
);
}
catch (StripeException e) {
throw new BusinessException("Payment processing failed: " + e.getMessage());
}
}
private void recalculate(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);
cart.setSubtotalAmount(subtotal);
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);
cart.setDiscountAmount(discount);
cart.setTotalAmount(subtotal.subtract(discount).max(BigDecimal.ZERO));
cartRepository.save(cart);
}
private CartResponse toResponse(Cart cart) {
List<CartItemResponse> itemResponses = cartItemRepository
.findByCartCartId(cart.getCartId())
.stream()
.map(item -> new CartItemResponse(
item.getCartItemId(),
item.getProduct().getProdId(),
item.getProduct().getProdName(),
item.getProduct().getImageUrl(),
item.getUnitPrice(),
item.getQuantity(),
item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))
))
.toList();
CartResponse response = new CartResponse();
response.setCartId(cart.getCartId());
response.setStoreId(cart.getStore() != null ? cart.getStore().getStoreId() : null);
response.setCartStatus(cart.getCartStatus());
response.setItems(itemResponses);
response.setSubtotalAmount(cart.getSubtotalAmount());
response.setDiscountAmount(cart.getDiscountAmount());
response.setTotalAmount(cart.getTotalAmount());
response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null);
return response;
}
}

View File

@@ -50,6 +50,9 @@ jwt:
secret: ${JWT_SECRET:change_me_please_make_this_at_least_32_characters_long_for_security}
expiration: ${JWT_EXPIRATION:86400000}
stripe:
secret-key: ${STRIPE_SECRET_KEY:}
logging:
level:
com.petshop: ${LOG_LEVEL:INFO}