205 lines
7.4 KiB
TypeScript
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]));
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|