Files
dkala-api/src/modules/cart/providers/cart.service.ts
T
2026-06-28 11:35:13 +03:30

412 lines
13 KiB
TypeScript

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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<Cart> {
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<void> {
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> {
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();
}
}