Files
backend/src/cart/cart.service.ts
T
Alireza HassaniandCursor 92601ae729 Add page-view analytics, product counters, SSL expiry, and business renewal.
Auto-count storefront page views with 6-month event retention and lifetime counters, track product visit/cart/sale stats, persist SSL cert expiry with daily probes, and support owner titles plus annual contract renewal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:13:06 +03:30

468 lines
13 KiB
TypeScript

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';
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,
) {}
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: 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: 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: 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: 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: 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: 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 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,
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<CartWithItems> {
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 serializeCart(cart: CartWithItems) {
const items = cart.items.map((item) => this.serializeCartItem(item));
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]) {
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,
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<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
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');
}
}
}