Files
dkala-api/src/modules/cart/providers/cart-validation.service.ts
T
2026-02-10 12:40:44 +03:30

205 lines
7.4 KiB
TypeScript

import { Injectable, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Product } from 'src/modules/products/entities/product.entity';
import { UserAddress } from 'src/modules/users/entities/user-address.entity';
import { PaymentMethod } from 'src/modules/payments/entities/payment-method.entity';
import { Delivery } from 'src/modules/delivery/entities/delivery.entity';
import { DeliveryMethodEnum } from 'src/modules/delivery/interface/delivery';
import { Order } from 'src/modules/orders/entities/order.entity';
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
import { Cart } from '../interfaces/cart.interface';
import { GeographicUtils } from '../utils/geographic.utils';
import { CartMessage } from 'src/common/enums/message.enum';
import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository';
import { ProductService } from 'src/modules/products/providers/product.service';
import { ShopService } from 'src/modules/shops/providers/shops.service';
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
import { Variant } from 'src/modules/products/entities/variant.entity';
@Injectable()
export class CartValidationService {
constructor(
private readonly em: EntityManager,
private readonly walletTransactionRepository: WalletTransactionRepository,
private readonly productService: ProductService,
private readonly shopService: ShopService,
private readonly deliveryService: DeliveryService,
private readonly paymentMethodService: PaymentMethodService,
) { }
/**
* Validate product exists and belongs to shop
*/
async validateAndGetVariant(variantId: string, shopId: string, quantity: number): Promise<Variant> {
const variant = await this.productService.findOrFailVariant(variantId)
if (variant.product.shop.id !== shopId) {
throw new BadRequestException(CartMessage.PRODUCT_NOT_BELONGS_TO_SHOP);
}
return variant;
}
/**
* Validate address belongs to user
*/
validateAddressOwnership(address: UserAddress, userId: string): void {
if (address.user.id !== userId) {
throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER);
}
}
/**
* Assert address is inside shop service area
*/
async assertAddressInsideServiceArea(
shopId: string,
latitude?: number | null,
longitude?: number | null,
): Promise<void> {
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
}
const shop = await this.shopService.findOrFail(shopId);
const serviceArea = shop.serviceArea;
// If no service area is defined, assume service is available everywhere
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
const rings = serviceArea.coordinates;
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
const ring = rings[0];
const point: [number, number] = [Number(longitude), Number(latitude)];
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
const polygon: [number, number][] = ring.map(
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
);
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA);
}
}
/**
* Get enabled delivery method or throw if not found or disabled
*/
async getEnabledDeliveryMethodOrFail(shopId: string, deliveryMethodId: string): Promise<Delivery> {
const deliveryMethod = await this.deliveryService.findOrFail(shopId, deliveryMethodId)
if (!deliveryMethod.enabled) {
throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED);
}
return deliveryMethod;
}
/**
* Assert delivery method matches expected type
*/
assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void {
if (actual !== expected) {
throw new BadRequestException(message);
}
}
/**
* Require delivery method ID or throw
*/
requireDeliveryMethodId(cart: Cart, message: string): string {
if (!cart.deliveryMethodId) {
throw new BadRequestException(message);
}
return cart.deliveryMethodId;
}
/**
* Get enabled payment method or throw if not found or disabled
*/
async getEnabledPaymentMethodOrFail(shopId: string, paymentMethodId: string): Promise<PaymentMethod> {
const paymentMethod = await this.paymentMethodService.findOneOrFail(paymentMethodId)
if (paymentMethod.shop.id !== shopId) {
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_BELONGS_TO_SHOP);
}
if (!paymentMethod.enabled) {
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED);
}
return paymentMethod;
}
/**
* Assert wallet has enough balance
*/
async assertWalletHasEnoughBalance(userId: string, shopId: string, amount: number): Promise<void> {
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, shopId);
if (balance < amount) {
throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT);
}
}
/**
* Assert coupon usage limit
*/
async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise<void> {
if (!maxUsesPerUser) return;
const ordersThatUsedTheCouponCount = await this.em.count(Order, {
user: { id: userId },
couponDetail: { couponId: couponId },
status: { $ne: OrderStatus.CANCELED },
deletedAt: null,
});
if (ordersThatUsedTheCouponCount >= maxUsesPerUser) {
throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED);
}
}
/**
* Assert coupon matches cart if restricted
*/
async assertCouponMatchesCartIfRestricted(
cart: Cart,
foodCategories?: string[] | null,
products?: string[] | null,
): Promise<void> {
const categoryRestriction = foodCategories?.filter(Boolean) ?? [];
const foodRestriction = products?.filter(Boolean) ?? [];
const hasCategoryRestriction = categoryRestriction.length > 0;
const hasFoodRestriction = foodRestriction.length > 0;
if (!hasCategoryRestriction && !hasFoodRestriction) return;
const foodMap = await this.getFoodsInCartWithCategories(cart);
for (const item of cart.items) {
const product = foodMap.get(item.variantId);
if (!product) continue;
const matchesCategory = hasCategoryRestriction && product.category && categoryRestriction.includes(product.category.id);
const matchesFood = hasFoodRestriction && foodRestriction.includes(product.id);
if (matchesCategory || matchesFood) return;
}
throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED);
}
/**
* Get products in cart with categories
*/
async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Product>> {
const productIds = cart.items.map(item => item.variantId);
if (productIds.length === 0) return new Map();
const products = await this.em.find(Product, { id: { $in: productIds } }, { populate: ['category'] });
return new Map(products.map(f => [f.id, f]));
}
}