import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { DeliveryMethodEnum } from '../../../delivery/interface/delivery'; import { PaymentMethodEnum } from '../../../payments/interface/payment'; import { OrderCouponDetail } from '../../../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 '../../../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 { SetCarDeliveryDto } from '../dto/set-car-delivery.dto'; import { CartMessage } from 'src/common/enums/message.enum'; @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, ) { } /** * Set all cart parameters at once */ async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise { const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber } = params; // get existing cart (or throw) const cart = await this.findOneOrFail(userId, restaurantId); // Validate and get delivery method const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( restaurantId, deliveryMethodId, ); cart.deliveryMethodId = deliveryMethodId; // Handle each delivery method with its specific requirements switch (deliveryMethod.method) { case DeliveryMethodEnum.DineIn: // DineIn requires table number and clears incompatible fields if (!tableNumber) { throw new BadRequestException(CartMessage.TABLE_NUMBER_REQUIRED); } this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn); cart.tableNumber = tableNumber; break; case DeliveryMethodEnum.CustomerPickup: // CustomerPickup just clears incompatible fields this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup); break; case DeliveryMethodEnum.DeliveryCar: // DeliveryCar requires carAddress and clears incompatible fields if (!carAddress) { throw new BadRequestException(CartMessage.CAR_ADDRESS_REQUIRED); } this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar); const user = await this.validationService.getUserOrFail(userId); cart.carAddress = { carModel: carAddress.carModel, carColor: carAddress.carColor, plateNumber: carAddress.plateNumber, phone: user.phone, }; 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.validationService.getUserAddressOrFail(addressId); this.validationService.validateAddressOwnership(address, userId); // ensure address is within shop service area await this.validationService.assertAddressInsideServiceArea( restaurantId, 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( restaurantId, paymentMethodId, ); // Recalculate totals first so wallet check uses up-to-date total (delivery method may have changed above). await this.calculationService.recalculateCartTotals(cart); if (paymentMethod.method === PaymentMethodEnum.Wallet) { await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total); } cart.paymentMethodId = paymentMethodId; // Set description (if provided) if (description !== undefined) { cart.description = description; } // Final recalculation + save and return cart return this.recalculateAndSaveCart(cart); } /** * Get or create cart for user and shop */ async getOrCreateCart(userId: string, restaurantId: string): Promise { const existingCart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId); if (existingCart) { return existingCart; } // Find shop const shop = await this.validationService.getRestaurantOrFail(restaurantId); // Create new cart const now = this.nowIso(); const cart: Cart = { userId, restaurantId, restaurantName: 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, restaurantId: string): Promise { const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId); if (!cart) { throw new NotFoundException(CartMessage.NOT_FOUND); } return cart; } /** * Increment item quantity in cart */ async incrementItem(userId: string, restaurantId: string, foodId: string, quantity: number): Promise { const cart = await this.getOrCreateCart(userId, restaurantId); await this.itemService.addOrIncrementItem(cart, foodId, restaurantId, quantity); return this.recalculateAndSaveCart(cart); } /** * Decrement item quantity in cart by 1 (removes item if quantity reaches 0) */ async decrementItem(userId: string, restaurantId: string, foodId: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); await this.itemService.decrementOrRemoveItem(cart, foodId); return this.recalculateAndSaveCart(cart); } /** * Bulk add items to cart (increments quantity if items exist) */ async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise { const cart = await this.getOrCreateCart(userId, restaurantId); for (const addItemDto of bulkAddItemsDto.items) { await this.itemService.addOrIncrementItem(cart, addItemDto.foodId, restaurantId, addItemDto.quantity); } return this.recalculateAndSaveCart(cart); } /** * Remove item from cart */ async removeItem(userId: string, restaurantId: string, foodId: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); this.itemService.removeItemOrFail(cart, foodId); return this.recalculateAndSaveCart(cart); } /** * Apply coupon to cart */ async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const orderAmount = cart.subTotal - cart.itemsDiscount; const user = await this.validationService.getUserOrFail(userId); // check if coupon is valid and belong to the shop const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, restaurantId, 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.id, coupon.maxUsesPerUser); await this.validationService.assertCouponMatchesCartIfRestricted( cart, coupon.foodCategories, coupon.products, ); const couponDetail: OrderCouponDetail = { couponId: coupon.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, restaurantId: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); cart.coupon = undefined; return this.recalculateAndSaveCart(cart); } /** * Set address for cart */ async setAddress(userId: string, restaurantId: string, addressId: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const deliveryMethodId = this.validationService.requireDeliveryMethodId( cart, 'Delivery method must be set before setting address', ); const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, deliveryMethodId, ); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DeliveryCourier, 'Delivery method must be DeliveryCourier', ); // Find and validate address belongs to user const address = await this.validationService.getUserAddressOrFail(addressId); // Verify address belongs to the user this.validationService.validateAddressOwnership(address, userId); // ensure address is within shop service area await this.validationService.assertAddressInsideServiceArea( restaurantId, 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.saveTouchedCart(cart); } /** * Set payment method for cart */ async setPaymentMethod( userId: string, restaurantId: string, paymentMethodId: string, ): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail( restaurantId, paymentMethodId, ); if (paymentMethod.method === PaymentMethodEnum.Wallet) { await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total); } cart.paymentMethodId = paymentMethodId; return this.recalculateAndSaveCart(cart); } /** * Set delivery method for cart */ async setDeliveryMethod( userId: string, restaurantId: string, deliveryMethodId: string, ): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( restaurantId, deliveryMethodId, ); cart.deliveryMethodId = deliveryMethodId; this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); return this.recalculateAndSaveCart(cart); } /** * Set description for cart */ async setDescription(userId: string, restaurantId: string, description: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); cart.description = description; return this.saveTouchedCart(cart); } /** * Set table number for cart */ async setTableNumber(userId: string, restaurantId: string, tableNumber: string): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const deliveryMethodId = this.validationService.requireDeliveryMethodId( cart, 'Delivery method must be set before setting table number', ); const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, deliveryMethodId, ); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DineIn, 'Delivery method must be DineIn', ); this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn); cart.tableNumber = tableNumber; return this.saveTouchedCart(cart); } /** * Set car delivery for cart */ async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const deliveryMethodId = this.validationService.requireDeliveryMethodId( cart, 'Delivery method must be set before setting car delivery', ); const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, deliveryMethodId, ); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DeliveryCar, 'Delivery method must be DeliveryCar', ); const user = await this.validationService.getUserOrFail(userId); this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar); cart.carAddress = { carModel: setCarDeliveryDto.carModel, carColor: setCarDeliveryDto.carColor, plateNumber: setCarDeliveryDto.plateNumber, phone: user.phone, }; return this.saveTouchedCart(cart); } /** * Clear cart from cache */ async clearCart(userId: string, restaurantId: string): Promise { return this.cartRepository.delete(userId, restaurantId); } /** * Clears cart fields that are not applicable for a given delivery method. */ private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void { if (method === DeliveryMethodEnum.DineIn) { cart.userAddress = null; cart.carAddress = null; return; } if (method === DeliveryMethodEnum.CustomerPickup) { cart.userAddress = null; cart.carAddress = null; cart.tableNumber = undefined; return; } if (method === DeliveryMethodEnum.DeliveryCourier) { cart.carAddress = null; cart.tableNumber = undefined; return; } if (method === DeliveryMethodEnum.DeliveryCar) { cart.userAddress = null; 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(); } }