import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { ContentStatus, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { AddCartItemDto, CheckoutCartDto, ShippingAddressDto, UpdateCartItemDto, } from './dto/cart.dto'; import { OrdersService } from '../orders/orders.service'; import { PaymentsService } from '../payments/payments.service'; import { TransactionType } from '@prisma/client'; import { BusinessSettingsService } from '../business-settings/business-settings.service'; import type { PaymentGatewayId } from '../business-settings/business-settings.types'; import { listPublicPaymentGateways } from '../business-settings/business-settings.util'; import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service'; const cartVariantInclude = { storeItem: { include: { product: { include: { featuredMedia: true, }, }, }, }, selections: { include: { variation: true, option: true, }, }, } satisfies Prisma.StoreItemVariantInclude; type CartWithItems = Prisma.CartGetPayload<{ include: { items: { include: { storeItemVariant: { include: typeof cartVariantInclude; }; }; }; }; }>; @Injectable() export class CartService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly orders: OrdersService, private readonly payments: PaymentsService, private readonly businessSettings: BusinessSettingsService, private readonly shippingCostGroups: ShippingCostGroupsService, ) {} async getCart(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); const cart = await this.getOrCreateCart(businessId, actor.id); return { cart: await this.serializeCart(cart) }; } async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const storeItemVariantId = BigInt(dto.storeItemVariantId); const quantity = dto.quantity ?? 1; await this.assertCustomerAccess(businessId, actor); const variant = await this.loadPurchasableVariant(businessId, storeItemVariantId); this.assertStockAvailable(variant.stockQuantity, quantity); const cart = await this.getOrCreateCart(businessId, actor.id); const existing = cart.items.find( (item) => item.storeItemVariantId === storeItemVariantId, ); if (existing) { const newQuantity = existing.quantity + quantity; this.assertStockAvailable(variant.stockQuantity, newQuantity); await this.prisma.cartItem.update({ where: { id: existing.id }, data: { quantity: newQuantity }, }); await this.bumpProductCartAdd(variant.storeItem.productId, quantity); const refreshed = await this.loadCart(cart.id); return { message: 'Cart item quantity updated', cart: await this.serializeCart(refreshed), }; } await this.prisma.cartItem.create({ data: { cartId: cart.id, storeItemVariantId, quantity, }, }); await this.bumpProductCartAdd(variant.storeItem.productId, quantity); const refreshed = await this.loadCart(cart.id); return { message: 'Item added to cart', cart: await this.serializeCart(refreshed), }; } async updateItem( businessIdRaw: string, itemIdRaw: string, dto: UpdateCartItemDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const itemId = BigInt(itemIdRaw); await this.assertCustomerAccess(businessId, actor); const cart = await this.getOrCreateCart(businessId, actor.id); const item = cart.items.find((entry) => entry.id === itemId); if (!item) { throw new NotFoundException('Cart item not found'); } this.assertStockAvailable(item.storeItemVariant.stockQuantity, dto.quantity); await this.prisma.cartItem.update({ where: { id: itemId }, data: { quantity: dto.quantity }, }); const refreshed = await this.loadCart(cart.id); return { message: 'Cart item updated', cart: await this.serializeCart(refreshed), }; } async removeItem(businessIdRaw: string, itemIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const itemId = BigInt(itemIdRaw); await this.assertCustomerAccess(businessId, actor); const cart = await this.getOrCreateCart(businessId, actor.id); const item = cart.items.find((entry) => entry.id === itemId); if (!item) { throw new NotFoundException('Cart item not found'); } await this.prisma.cartItem.delete({ where: { id: itemId } }); const refreshed = await this.loadCart(cart.id); return { message: 'Cart item removed', cart: await this.serializeCart(refreshed), }; } async clear(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); const cart = await this.getOrCreateCart(businessId, actor.id); await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } }); const refreshed = await this.loadCart(cart.id); return { message: 'Cart cleared', cart: await this.serializeCart(refreshed), }; } async checkout( businessIdRaw: string, dto: CheckoutCartDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertCustomerAccess(businessId, actor); const cart = await this.getOrCreateCart(businessId, actor.id); if (!cart.items.length) { throw new BadRequestException('Cart is empty'); } const shippingAddress = await this.resolveShippingAddress( actor.id, dto.addressId, dto.shippingAddress, ); const deliveryMode = dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery' ? dto.deliveryMode : dto.addressId ? 'delivery' : 'pickup'; const selectedMethodsByGroupId: Record = {}; for (const group of dto.shippingGroups ?? []) { if (group.methodId?.trim()) { selectedMethodsByGroupId[group.groupId] = group.methodId.trim(); } } const productIds = [ ...new Set( cart.items.map((item) => item.storeItemVariant.storeItem.product.id), ), ]; const assignments = productIds.length === 0 ? [] : await this.prisma.categoryAssignment.findMany({ where: { businessId, entityType: 'product', entityId: { in: productIds }, }, select: { entityId: true, categoryId: true }, }); const categoryIdsByProduct = new Map(); for (const row of assignments) { const key = row.entityId.toString(); const list = categoryIdsByProduct.get(key) ?? []; list.push(row.categoryId.toString()); categoryIdsByProduct.set(key, list); } const shippingQuote = await this.shippingCostGroups.quoteForCheckout({ businessId, deliveryMode, provinceName: deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null, cartItems: cart.items.map((item) => { const product = item.storeItemVariant.storeItem.product; const content = this.asRecord(product.content); const nameFa = (content.nameFa as string | null | undefined)?.trim(); return { productName: nameFa || product.title, categoryIds: categoryIdsByProduct.get(product.id.toString()) ?? [], }; }), selectedMethodsByGroupId, }); const enrichedShippingAddress = { ...shippingAddress, deliveryMode, shipping: { total: shippingQuote.total, groups: shippingQuote.groups, }, }; const isEPayment = dto.payment.type === TransactionType.e_payment_gate; let gatewayType: PaymentGatewayId | undefined; if (isEPayment) { if (!dto.returnUrl?.trim()) { throw new BadRequestException( 'returnUrl is required for e-payment checkout', ); } const settings = await this.businessSettings.getNormalizedSettings(businessId); const publicMethods = listPublicPaymentGateways(settings.store.ePayment); if (!publicMethods.enabled) { throw new BadRequestException( 'Online payment is not enabled for this store', ); } gatewayType = this.payments.resolveGatewayType( dto.payment.gatewayType, publicMethods, ); } const order = await this.orders.createFromCart({ businessId, userId: actor.id, createdBy: actor.id, source: 'website', cartItems: cart.items, shippingAddress: enrichedShippingAddress, shippingTotal: shippingQuote.total, addressId: dto.addressId ? BigInt(dto.addressId) : null, customerNotes: dto.customerNotes?.trim() || null, adminNotes: null, status: 'pending', payment: { type: dto.payment.type, posType: dto.payment.posType, gatewayType: isEPayment ? gatewayType : dto.payment.gatewayType, transferAccount: dto.payment.transferAccount, transferRefNumber: dto.payment.transferRefNumber, notes: dto.payment.notes?.trim() || null, }, }); if (isEPayment && gatewayType) { const payment = await this.payments.initiateForCheckout({ businessId, order: { id: order.id, total: order.total, transactions: order.transactions, }, gatewayType, returnUrl: dto.returnUrl!.trim(), }); await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } }); return { message: 'Redirect customer to payment gateway', order, payment: { gatewayType: payment.gatewayType, transactionId: payment.transactionId, redirect: payment.redirect, }, }; } await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } }); return { message: 'Order placed successfully', order, }; } private async getOrCreateCart(businessId: bigint, userId: bigint) { // Concurrent GETs (e.g. React Strict Mode) can race on create; upsert is safe. const cart = await this.prisma.cart.upsert({ where: { businessId_userId: { businessId, userId }, }, create: { businessId, userId }, update: {}, }); return this.loadCart(cart.id); } private async loadCart(cartId: bigint): Promise { return this.prisma.cart.findUniqueOrThrow({ where: { id: cartId }, include: { items: { orderBy: { createdAt: 'asc' }, include: { storeItemVariant: { include: cartVariantInclude, }, }, }, }, }); } private async bumpProductCartAdd(productId: bigint, quantity: number) { if (!Number.isFinite(quantity) || quantity <= 0) return; await this.prisma.product.update({ where: { id: productId }, data: { cartAddCount: { increment: quantity } }, }); } private async loadPurchasableVariant( businessId: bigint, storeItemVariantId: bigint, ) { const variant = await this.prisma.storeItemVariant.findFirst({ where: { id: storeItemVariantId, businessId, isActive: true }, include: { storeItem: { include: { product: true, }, }, }, }); if (!variant) { throw new NotFoundException('Store item variant not found or unavailable'); } if (variant.storeItem.product.status !== ContentStatus.published) { throw new BadRequestException('Product is not available for purchase'); } if (variant.price === null) { throw new BadRequestException('Store item variant has no price configured'); } return variant; } private async resolveShippingAddress( userId: bigint, addressIdRaw: string | undefined, inline: ShippingAddressDto | undefined, ) { if (addressIdRaw) { const address = await this.prisma.address.findFirst({ where: { id: BigInt(addressIdRaw), userId }, }); if (!address) { throw new NotFoundException('Shipping address not found'); } return { province: address.province, city: address.city, address: address.address, postalCode: address.postalCode, landline: address.landline, }; } if (!inline) { throw new BadRequestException( 'Provide addressId or shippingAddress for checkout', ); } return { province: inline.province.trim(), city: inline.city.trim(), address: inline.address.trim(), postalCode: inline.postalCode?.trim() || null, landline: inline.landline?.trim() || null, }; } private assertStockAvailable(stockQuantity: number | null, requested: number) { if (stockQuantity !== null && requested > stockQuantity) { throw new BadRequestException('Insufficient stock for this quantity'); } } private async serializeCart(cart: CartWithItems) { const productIds = [ ...new Set( cart.items.map((item) => item.storeItemVariant.storeItem.product.id), ), ]; const assignments = productIds.length === 0 ? [] : await this.prisma.categoryAssignment.findMany({ where: { businessId: cart.businessId, entityType: 'product', entityId: { in: productIds }, }, select: { entityId: true, categoryId: true }, }); const categoryIdsByProduct = new Map(); for (const row of assignments) { const key = row.entityId.toString(); const list = categoryIdsByProduct.get(key) ?? []; list.push(row.categoryId.toString()); categoryIdsByProduct.set(key, list); } const items = cart.items.map((item) => { const productId = item.storeItemVariant.storeItem.product.id.toString(); return this.serializeCartItem( item, categoryIdsByProduct.get(productId) ?? [], ); }); const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0); return { id: cart.id.toString(), businessId: cart.businessId.toString(), items, itemCount: items.reduce((sum, item) => sum + item.quantity, 0), subtotal, updatedAt: cart.updatedAt, }; } private serializeCartItem( item: CartWithItems['items'][number], categoryIds: string[], ) { const variant = item.storeItemVariant; const product = variant.storeItem.product; const content = this.asRecord(product.content); const price = Number(variant.price); const compareAtPrice = variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); const effectivePrice = compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price; const selections = variant.selections.map((selection) => ({ variationId: selection.variation.id.toString(), variationName: selection.variation.name, optionId: selection.option.id.toString(), value: selection.option.label, })); return { id: item.id.toString(), storeItemId: variant.storeItemId.toString(), storeItemVariantId: variant.id.toString(), productId: product.id.toString(), productTitle: product.title, productNameFa: (content.nameFa as string | null | undefined) ?? '', productImage: product.featuredMedia?.publicUrl ?? null, categoryIds, sku: variant.sku, selections, label: selections.map((entry) => entry.value).join(' ยท ') || product.title, quantity: item.quantity, unitPrice: effectivePrice, compareAtPrice: price, lineTotal: effectivePrice * item.quantity, stockQuantity: variant.stockQuantity, }; } private asRecord(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } private async assertCustomerAccess(businessId: bigint, actor: AuthUser) { if (await this.permissions.isSuperAdmin(actor.id)) { return; } const membership = await this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId: actor.id }, }, }); if (!membership) { throw new ForbiddenException('You are not a customer of this business'); } } }