Wire cart/checkout shipping fees with category groups and static rates, reject gibberish contact submissions, and SMS only valid Iranian mobiles. Co-authored-by: Cursor <cursoragent@cursor.com>
572 lines
17 KiB
TypeScript
572 lines
17 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';
|
|
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<string, string> = {};
|
|
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<string, string[]>();
|
|
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<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 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<string, string[]>();
|
|
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<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');
|
|
}
|
|
}
|
|
}
|