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 { SetAddressDto } from '../dto/set-address.dto'; import { SetPaymentMethodDto } from '../dto/set-payment-method.dto'; import { SetDeliveryMethodDto } from '../dto/set-delivery-method.dto'; import { SetDescriptionDto } from '../dto/set-description.dto'; import { SetTableNumberDto } from '../dto/set-table-number.dto'; import { SetCarDeliveryDto } from '../dto/set-car-delivery.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'; @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 } = params; // get existing cart (or throw) const cart = await this.findOneOrFail(userId, restaurantId); // If deliveryMethodId is provided -> validate and set it first if (deliveryMethodId) { const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( restaurantId, deliveryMethodId, ); cart.deliveryMethodId = deliveryMethodId; // clear fields incompatible with the chosen delivery method this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); } // If addressId is provided -> must have delivery method (either provided above or already on cart) if (addressId) { const effectiveDeliveryMethodId = deliveryMethodId ?? cart.deliveryMethodId; if (!effectiveDeliveryMethodId) { throw new BadRequestException('Delivery method must be set before setting address'); } const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, effectiveDeliveryMethodId, ); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DeliveryCourier, 'Delivery method must be DeliveryCourier', ); const address = await this.validationService.getUserAddressOrFail(addressId); this.validationService.validateAddressOwnership(address, userId); // ensure address is within restaurant service area await this.validationService.assertAddressInsideServiceArea( restaurantId, address.latitude, address.longitude, ); // ensure cart fields are compatible and set address 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, }; } // If carAddress is provided -> must have delivery method DeliveryCar if (carAddress) { const effectiveDeliveryMethodId = deliveryMethodId ?? cart.deliveryMethodId; if (!effectiveDeliveryMethodId) { throw new BadRequestException('Delivery method must be set before setting car delivery'); } const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, effectiveDeliveryMethodId, ); this.validationService.assertDeliveryMethod( deliveryMethod.method, DeliveryMethodEnum.DeliveryCar, 'Delivery method must be DeliveryCar', ); const user = await this.validationService.getUserOrFail(userId); // make sure incompatible fields are cleared and set car address (phone comes from user) this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar); cart.carAddress = { carModel: carAddress.carModel, carColor: carAddress.carColor, plateNumber: carAddress.plateNumber, phone: user.phone, }; } // If payment method is provided -> validate and (for wallet) ensure enough balance. if (paymentMethodId) { 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; } // Description (if provided) if (description !== undefined) { cart.description = description; } // Final validation: if effective delivery method is courier, ensure all foods have pickupServe const effectiveDeliveryMethodId = deliveryMethodId ?? cart.deliveryMethodId; if (effectiveDeliveryMethodId) { const effectiveDeliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail( restaurantId, effectiveDeliveryMethodId, ); await this.validationService.assertAllFoodsHavePickupServeForCourier(cart, effectiveDeliveryMethod.method); } // Final recalculation + save and return cart return this.recalculateAndSaveCart(cart); } /** * Get or create cart for user and restaurant */ async getOrCreateCart(userId: string, restaurantId: string): Promise { const existingCart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId); if (existingCart) { return existingCart; } // Find restaurant const restaurant = await this.validationService.getRestaurantOrFail(restaurantId); // Create new cart const now = this.nowIso(); const cart: Cart = { userId, restaurantId, restaurantName: restaurant.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 restaurant */ async findOneOrFail(userId: string, restaurantId: string): Promise { const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId); if (!cart) { throw new NotFoundException('Cart 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 restaurant const { valid, coupon, message } = await this.couponService.validateCoupon( applyCouponDto.code, restaurantId, Number(orderAmount), user.phone, ); if (!valid) { throw new BadRequestException(message || 'Invalid coupon code'); } if (!coupon) { throw new BadRequestException('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.foods, ); 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, setAddressDto: SetAddressDto): 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(setAddressDto.addressId); // Verify address belongs to the user this.validationService.validateAddressOwnership(address, userId); // ensure address is within restaurant 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, setPaymentMethodDto: SetPaymentMethodDto, ): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail( restaurantId, setPaymentMethodDto.paymentMethodId, ); if (paymentMethod.method === PaymentMethodEnum.Wallet) { await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total); } cart.paymentMethodId = setPaymentMethodDto.paymentMethodId; return this.recalculateAndSaveCart(cart); } /** * Set delivery method for cart */ async setDeliveryMethod( userId: string, restaurantId: string, setDeliveryMethodDto: SetDeliveryMethodDto, ): Promise { const cart = await this.findOneOrFail(userId, restaurantId); const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail( restaurantId, setDeliveryMethodDto.deliveryMethodId, ); cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId; this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method); return this.recalculateAndSaveCart(cart); } /** * Set description for cart */ async setDescription(userId: string, restaurantId: string, setDescriptionDto: SetDescriptionDto): Promise { const cart = await this.findOneOrFail(userId, restaurantId); cart.description = setDescriptionDto.description; return this.saveTouchedCart(cart); } /** * Set table number for cart */ async setTableNumber(userId: string, restaurantId: string, setTableNumberDto: SetTableNumberDto): 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 = setTableNumberDto.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(); } }