513 lines
17 KiB
TypeScript
513 lines
17 KiB
TypeScript
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<Cart> {
|
|
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<void> {
|
|
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> {
|
|
cart.updatedAt = this.nowIso();
|
|
await this.cartRepository.save(cart);
|
|
return cart;
|
|
}
|
|
|
|
/**
|
|
* Recalculate cart totals and save
|
|
*/
|
|
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
|
|
await this.calculationService.recalculateCartTotals(cart);
|
|
await this.cartRepository.save(cart);
|
|
return cart;
|
|
}
|
|
|
|
/**
|
|
* Get current ISO timestamp
|
|
*/
|
|
private nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
}
|