import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { DeliveryMethodEnum } from 'src/modules/delivery/interface/delivery'; import { PaymentMethodEnum } from 'src/modules/payments/interface/payment'; import { OrderCouponDetail } from 'src/modules/orders/interface/order.interface'; import { Cart } from '../interfaces/cart.interface'; import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto'; import { ApplyCouponDto } from '../dto/apply-coupon.dto'; import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto'; import { CouponService } from 'src/modules/coupons/providers/coupon.service'; import { CartRepository } from '../repositories/cart.repository'; import { CartValidationService } from './cart-validation.service'; import { CartCalculationService } from './cart-calculation.service'; import { CartItemService } from './cart-item.service'; import { CartMessage } from 'src/common/enums/message.enum'; import { UserService } from 'src/modules/users/providers/user.service'; import { ShopService } from 'src/modules/shops/providers/shops.service'; import { DeliveryService } from 'src/modules/delivery/providers/delivery.service'; @Injectable() export class CartService { constructor( private readonly cartRepository: CartRepository, private readonly validationService: CartValidationService, private readonly calculationService: CartCalculationService, private readonly itemService: CartItemService, private readonly couponService: CouponService, private readonly userService: UserService, private readonly shopService: ShopService, private readonly deliveryService: DeliveryService, ) { } /** * Set all cart parameters at once */ async setAllCartParams(userId: string, shopId: string, params: SetAllCartParmsDto): Promise { const { deliveryMethodId, paymentMethodId, addressId, description } = params; // get existing cart (or throw) const cart = await this.findOneOrFail(userId, shopId); // Validate and get delivery method const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( shopId, deliveryMethodId, ); cart.deliveryMethodId = deliveryMethodId; // Handle each delivery method with its specific requirements switch (deliveryMethod.method) { case DeliveryMethodEnum.CustomerPickup: // CustomerPickup just clears incompatible fields this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup); break; case DeliveryMethodEnum.DeliveryCourier: // DeliveryCourier requires addressId and clears incompatible fields if (!addressId) { throw new BadRequestException(CartMessage.ADDRESS_REQUIRED); } this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier); const address = await this.userService.getUserAddressOrFail(addressId); this.validationService.validateAddressOwnership(address, userId); // ensure address is within shop service area await this.validationService.assertAddressInsideServiceArea( shopId, address.latitude, address.longitude, ); cart.userAddress = { address: address.address, latitude: address.latitude, longitude: address.longitude, city: address.city, province: address.province || '', postalCode: address.postalCode || undefined, fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(), phone: address.user.phone, }; break; } // Validate and set payment method const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail( shopId, paymentMethodId, ); // Recalculate totals first so wallet check uses up-to-date total (delivery method/address may have changed above). await this.calculationService.recalculateCartTotals(cart); if (paymentMethod.method === PaymentMethodEnum.Wallet) { await this.validationService.assertWalletHasEnoughBalance(userId, shopId, cart.total); } cart.paymentMethodId = paymentMethodId; // Set description (if provided) if (description !== undefined) { cart.description = description; } // Save and return cart (totals already recalculated above) return this.saveTouchedCart(cart); } /** * Get or create cart for user and shop */ async getOrCreateCart(userId: string, shopId: string): Promise { const existingCart = await this.cartRepository.findByUserAndShop(userId, shopId); if (existingCart) { return existingCart; } // Find shop const shop = await this.shopService.findOrFail(shopId); // Create new cart const now = this.nowIso(); const cart: Cart = { userId, shopId: shop.id, shopName: shop.name, items: [], deliveryFee: 0, subTotal: 0, itemsDiscount: 0, couponDiscount: 0, totalDiscount: 0, tax: 0, total: 0, totalItems: 0, createdAt: now, updatedAt: now, }; await this.cartRepository.save(cart); return cart; } /** * Find cart by user and shop */ async findOneOrFail(userId: string, shopId: string): Promise { const cart = await this.cartRepository.findByUserAndShop(userId, shopId); if (!cart) { throw new NotFoundException(CartMessage.NOT_FOUND); } return cart; } /** * Increment item quantity in cart by product purchase step */ async incrementItemByStep(userId: string, shopId: string, variantId: string): Promise { const cart = await this.getOrCreateCart(userId, shopId); await this.itemService.incrementByStep(cart, variantId, shopId); return this.recalculateAndSaveCart(cart); } /** * Increment item quantity in cart */ async incrementItem(userId: string, shopId: string, variantId: string, quantity: number): Promise { const cart = await this.getOrCreateCart(userId, shopId); await this.itemService.addOrIncrementItem(cart, variantId, shopId, quantity); return this.recalculateAndSaveCart(cart); } /** * Decrement item quantity in cart by 1 (removes item if quantity reaches 0) */ async decrementItem(userId: string, shopId: string, variantId: string): Promise { const cart = await this.findOneOrFail(userId, shopId); await this.itemService.decrementOrRemoveItem(cart, variantId); return this.recalculateAndSaveCart(cart); } /** * Bulk add items to cart (increments quantity if items exist) */ async bulkAddItems(userId: string, shopId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise { const cart = await this.getOrCreateCart(userId, shopId); for (const addItemDto of bulkAddItemsDto.items) { await this.itemService.addOrIncrementItem(cart, addItemDto.variantId, shopId, addItemDto.quantity); } return this.recalculateAndSaveCart(cart); } /** * Remove item from cart */ async removeItem(userId: string, shopId: string, variantId: string): Promise { const cart = await this.findOneOrFail(userId, shopId); this.itemService.removeItemOrFail(cart, variantId); return this.recalculateAndSaveCart(cart); } /** * Apply coupon to cart */ async applyCoupon(userId: string, shopId: string, applyCouponDto: ApplyCouponDto): Promise { const cart = await this.findOneOrFail(userId, shopId); const orderAmount = cart.subTotal - cart.itemsDiscount; const user = await this.userService.findOneOrFail(userId); // check if coupon is valid and belong to the shop const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, shopId, Number(orderAmount), user.phone, ); if (!valid) { throw new BadRequestException(message || CartMessage.COUPON_NOT_FOUND); } if (!coupon) { throw new BadRequestException(CartMessage.COUPON_NOT_FOUND); } // Check maxUsesPerUser limit by counting how many orders this user has with this coupon await this.validationService.assertCouponUsageLimit(userId, (coupon as any).id, coupon.maxUsesPerUser); await this.validationService.assertCouponMatchesCartIfRestricted( cart, coupon.productCategories, coupon.products, ); const couponDetail: OrderCouponDetail = { couponId: (coupon as any).id, couponName: coupon.name, couponCode: coupon.code, type: coupon.type, value: coupon.value, maxDiscount: coupon.maxDiscount, }; cart.coupon = couponDetail; return this.recalculateAndSaveCart(cart); } /** * Remove coupon from cart */ async removeCoupon(userId: string, shopId: string): Promise { const cart = await this.findOneOrFail(userId, shopId); cart.coupon = undefined; return this.recalculateAndSaveCart(cart); } /** * Set address for cart */ async setAddress(userId: string, shopId: string, addressId: string): Promise { const cart = await this.findOneOrFail(userId, shopId); const deliveryMethodId = this.validationService.requireDeliveryMethodId( cart, 'Delivery method must be set before setting address', ); const deliveryMethod = await this.deliveryService.findOrFail(shopId, deliveryMethodId); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DeliveryCourier, 'Delivery method must be DeliveryCourier', ); // Find and validate address belongs to user const address = await this.userService.getUserAddressOrFail(addressId); // Verify address belongs to the user this.validationService.validateAddressOwnership(address, userId); // ensure address is within shop service area await this.validationService.assertAddressInsideServiceArea( shopId, address.latitude, address.longitude, ); this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier); cart.userAddress = { address: address.address, latitude: address.latitude, longitude: address.longitude, city: address.city, province: address.province || '', postalCode: address.postalCode || undefined, fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(), phone: address.user.phone, }; return this.recalculateAndSaveCart(cart); } /** * Set payment method for cart */ async setPaymentMethod( userId: string, shopId: string, paymentMethodId: string, ): Promise { const cart = await this.findOneOrFail(userId, shopId); const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail( shopId, paymentMethodId, ); // Recalculate totals first so wallet check uses up-to-date total await this.calculationService.recalculateCartTotals(cart); if (paymentMethod.method === PaymentMethodEnum.Wallet) { await this.validationService.assertWalletHasEnoughBalance(userId, shopId, cart.total); } cart.paymentMethodId = paymentMethodId; return this.recalculateAndSaveCart(cart); } /** * Set delivery method for cart */ async setDeliveryMethod( userId: string, shopId: string, deliveryMethodId: string, ): Promise { const cart = await this.findOneOrFail(userId, shopId); const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( shopId, deliveryMethodId, ); cart.deliveryMethodId = deliveryMethodId; this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); return this.recalculateAndSaveCart(cart); } /** * Set description for cart */ async setDescription(userId: string, shopId: string, description: string): Promise { const cart = await this.findOneOrFail(userId, shopId); cart.description = description; return this.saveTouchedCart(cart); } /** * Clear cart from cache */ async clearCart(userId: string, shopId: string): Promise { return this.cartRepository.delete(userId, shopId); } /** * Clears cart fields that are not applicable for a given delivery method. */ private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void { if (method === DeliveryMethodEnum.CustomerPickup) { cart.userAddress = null; cart.tableNumber = undefined; return; } if (method === DeliveryMethodEnum.DeliveryCourier) { cart.tableNumber = undefined; return; } } /** * Save cart with updated timestamp */ private async saveTouchedCart(cart: Cart): Promise { cart.updatedAt = this.nowIso(); await this.cartRepository.save(cart); return cart; } /** * Recalculate cart totals and save */ private async recalculateAndSaveCart(cart: Cart): Promise { await this.calculationService.recalculateCartTotals(cart); await this.cartRepository.save(cart); return cart; } /** * Get current ISO timestamp */ private nowIso(): string { return new Date().toISOString(); } }