Add SMS OTP, harden auth, and fix order/checkout server bugs.

Ship 4-digit OTP with rate-limit attempts, Tehran-safe expiry/rass math, larger JSON body limit for check images, and safer order pricing/status guards. Include rebuilt dist for prebuilt deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 14:55:26 +03:30
co-authored by Cursor
parent b121797263
commit 345a4a4708
100 changed files with 1476 additions and 353 deletions
+11
View File
@@ -25,6 +25,12 @@ export class SubmitOrderItemDto {
@Min(0.001)
meterage!: number;
/** Alias پذیرفته‌شده از کلاینت‌های قدیمی؛ نادیده گرفته می‌شود */
@IsOptional()
@Type(() => Number)
@IsNumber()
requestedMeterage?: number;
@IsOptional()
@IsString()
notes?: string;
@@ -91,6 +97,11 @@ export class SubmitOrderDto {
@IsEnum(SettlementType)
settlementType!: SettlementType;
/** Alias پذیرفته‌شده از کلاینت‌های قدیمی؛ نادیده گرفته می‌شود */
@IsOptional()
@IsEnum(SettlementType)
settlementMethod?: SettlementType;
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
+3 -12
View File
@@ -31,19 +31,13 @@ export class CustomerOrdersController {
@AuthType('customer')
@Get(':id')
findOne(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
findOne(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
return this.ordersService.findMyOrder(user.id, id);
}
@AuthType('customer')
@Post()
submit(
@CurrentUser() user: AuthPrincipal,
@Body() dto: SubmitOrderDto,
) {
submit(@CurrentUser() user: AuthPrincipal, @Body() dto: SubmitOrderDto) {
return this.ordersService.submitOrder(user.id, dto);
}
@@ -74,10 +68,7 @@ export class AdminOrdersController {
}
@Post()
create(
@CurrentUser() user: AuthPrincipal,
@Body() dto: AdminCreateOrderDto,
) {
create(@CurrentUser() user: AuthPrincipal, @Body() dto: AdminCreateOrderDto) {
return this.ordersService.adminCreate(user.id, dto);
}
+150 -97
View File
@@ -45,6 +45,23 @@ const orderInclude = {
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
/** مبالغ ریالی روی Float ذخیره می‌شوند؛ گرد کردن از انباشت خطای اعشاری جلوگیری می‌کند. */
function roundMoney(value: number): number {
return Math.round(value * 100) / 100;
}
/** بعد از تأیید چک‌ها یا ارسال سفارش، مشتری نباید بتواند چک‌ها را بازنویسی کند. */
const CHECK_EDITABLE_STATUSES: OrderStatus[] = [
OrderStatus.awaiting_review,
OrderStatus.pricing,
OrderStatus.needs_correction,
OrderStatus.awaiting_settlement_fix,
OrderStatus.check_in_progress,
OrderStatus.awaiting_check_review,
OrderStatus.check_needs_fix,
OrderStatus.check_rejected,
];
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
@@ -180,62 +197,62 @@ export class OrdersService {
? computeCashInvoiceExpiry(company)
: null;
const orderNumber = await this.nextOrderNumber();
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
},
},
},
include: orderInclude,
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
include: orderInclude,
});
}
return created;
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
});
}
return created;
}),
);
return this.mapOrder(order);
}
@@ -257,7 +274,9 @@ export class OrdersService {
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
let customer = await this.prisma.customer.findUnique({ where: { phone } });
let customer = await this.prisma.customer.findUnique({
where: { phone },
});
if (!customer) {
const names = splitFullName(dto.customerName || 'مشتری جدید');
customer = await this.prisma.customer.create({
@@ -274,67 +293,79 @@ export class OrdersService {
throw new BadRequestException('شناسه یا شماره مشتری الزامی است');
}
const orderNumber = await this.nextOrderNumber();
const totalMeterage = dto.meterage ?? 0;
const ownerId = userId;
const order = await this.prisma.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
statusHistory: {
create: {
fromStatus: null,
toStatus: OrderStatus.awaiting_review,
changedBy: `admin:${adminId}`,
note: 'سفارش توسط ادمین ثبت شد',
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
this.prisma.order.create({
data: {
orderNumber,
userId: ownerId,
companyId: company.id,
companyName: company.name,
settlementType,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
statusHistory: {
create: {
fromStatus: null,
toStatus: OrderStatus.awaiting_review,
changedBy: `admin:${adminId}`,
note: 'سفارش توسط ادمین ثبت شد',
},
},
},
},
include: orderInclude,
});
include: orderInclude,
}),
);
return this.mapOrder(order, true);
}
async updatePricing(
id: string,
adminId: string,
dto: UpdateOrderPricingDto,
) {
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
const order = await this.getOrder(id);
const unknown = dto.items.find(
(item) => !order.items.some((i) => i.id === item.id),
);
if (unknown) {
throw new BadRequestException(`آیتم ${unknown.id} یافت نشد`);
}
await this.prisma.$transaction(async (tx) => {
let totalAmount = 0;
let totalMeterage = 0;
for (const item of dto.items) {
const existing = order.items.find((i) => i.id === item.id);
if (!existing) {
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
}
// جمع کل باید روی همه آیتم‌های سفارش حساب شود، نه فقط آیتم‌های ارسالی
for (const existing of order.items) {
const item = dto.items.find((i) => i.id === existing.id);
const unitPrice =
item.unitPrice !== undefined
item?.unitPrice !== undefined
? item.unitPrice
: item.supplyPrice !== undefined
: item?.supplyPrice !== undefined
? item.supplyPrice
: (existing.unitPrice ?? 0);
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
const unitDiscount = item?.unitDiscount ?? existing.unitDiscount ?? 0;
if (unitDiscount > unitPrice) {
throw new BadRequestException(
'تخفیف واحد نمی‌تواند از قیمت واحد بیشتر باشد',
);
}
const confirmed =
item.confirmedMeterage ??
item?.confirmedMeterage ??
existing.confirmedMeterage ??
existing.requestedMeterage;
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
const lineTotal = roundMoney(
Math.max(0, (unitPrice - unitDiscount) * confirmed),
);
totalAmount += lineTotal;
totalMeterage += confirmed;
if (!item) continue;
await tx.orderItem.update({
where: { id: item.id },
where: { id: existing.id },
data: {
confirmedMeterage: confirmed,
unitPrice,
@@ -343,10 +374,13 @@ export class OrdersService {
},
});
}
totalAmount = roundMoney(totalAmount);
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount);
const finalAmount = roundMoney(
Math.max(0, totalAmount + shippingCost - totalDiscount),
);
const nextStatus =
order.status === OrderStatus.awaiting_review ||
@@ -382,11 +416,7 @@ export class OrdersService {
return this.findAdminOrder(id);
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateOrderStatusDto,
) {
async updateStatus(id: string, adminId: string, dto: UpdateOrderStatusDto) {
const order = await this.getOrder(id);
if (order.status === dto.status) {
return this.mapOrder(order, true);
@@ -435,8 +465,14 @@ export class OrdersService {
if (order.settlementType !== SettlementType.check) {
throw new BadRequestException('این سفارش تسویه چکی نیست');
}
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
throw new BadRequestException(
'در وضعیت فعلی سفارش امکان ویرایش چک‌ها وجود ندارد',
);
}
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const checkMode =
dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const rass = calculateCheckRass(
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
order.createdAt,
@@ -512,6 +548,23 @@ export class OrdersService {
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
}
/** بین انتخاب شماره و درج، امکان تصاحب شماره توسط درخواست هم‌زمان هست. */
private async createWithUniqueOrderNumber<T>(
create: (orderNumber: string) => Promise<T>,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const orderNumber = await this.nextOrderNumber();
try {
return await create(orderNumber);
} catch (e) {
const isDuplicate =
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002';
if (!isDuplicate || attempt >= 4) throw e;
}
}
}
private async getOrder(id: string): Promise<OrderFull> {
const order = await this.prisma.order.findUnique({
where: { id },