From 5e3e3958c5856a81797c9c234f24d4999b7567f1 Mon Sep 17 00:00:00 2001
From: parsa aghaei
Date: Sat, 5 Sep 2026 15:16:04 +0330
Subject: [PATCH] feat(backend): add wallet refund transactions and enhance SMS
automation rules
---
backend/prisma/schema.prisma | 3 +
backend/src/admin/admin.controller.ts | 3 +-
backend/src/admin/admin.service.ts | 68 ++++--
backend/src/admin/dto/product.dto.ts | 16 +-
.../common/services/sms-events.constants.ts | 156 ++++++++++---
backend/src/common/services/sms.service.ts | 206 ++++++++++--------
backend/src/orders/orders.service.ts | 49 +++--
backend/src/payment/payment.controller.ts | 3 +-
backend/src/payment/payment.service.ts | 143 ++++++++++--
backend/src/settings/settings.controller.ts | 14 +-
backend/src/settings/settings.service.ts | 18 +-
backend/src/users/users.service.ts | 8 +-
12 files changed, 490 insertions(+), 197 deletions(-)
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 188204e..9c5333e 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -402,6 +402,9 @@ model Order {
paymentMethod String? @default("card") @map("payment_method") @db.VarChar(30)
shippingAddress String? @map("shipping_address") @db.Text
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
+ refundStatus String? @default("none") @map("refund_status") @db.VarChar(30) // none, wallet, gateway, partial
+ refundedAmount Decimal @default(0.00) @map("refunded_amount") @db.Decimal(15, 2)
+ refundMethod String? @map("refund_method") @db.VarChar(30) // wallet, zibal
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts
index f74028b..ba5fc5d 100644
--- a/backend/src/admin/admin.controller.ts
+++ b/backend/src/admin/admin.controller.ts
@@ -204,7 +204,8 @@ export class AdminController {
@Body('trackingCode') trackingCode?: string,
@Body('trackingNumber') trackingNumber?: string,
) {
- const finalTracking = trackingNumber !== undefined ? trackingNumber : trackingCode;
+ const finalTracking =
+ trackingNumber !== undefined ? trackingNumber : trackingCode;
const order = await this.adminService.updateOrderStatus(
id,
status,
diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts
index aa7737f..4893611 100644
--- a/backend/src/admin/admin.service.ts
+++ b/backend/src/admin/admin.service.ts
@@ -859,22 +859,31 @@ export class AdminService {
}
if (!shipping.province || !shipping.address) {
let text = trimmed;
- const recipientMatch = text.match(/\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/);
+ const recipientMatch = text.match(
+ /\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/,
+ );
if (recipientMatch) {
- if (!shipping.fullName) shipping.fullName = recipientMatch[1]?.trim();
- if (!shipping.phone && recipientMatch[2]) shipping.phone = recipientMatch[2]?.trim();
+ if (!shipping.fullName)
+ shipping.fullName = recipientMatch[1]?.trim();
+ if (!shipping.phone && recipientMatch[2])
+ shipping.phone = recipientMatch[2]?.trim();
text = text.replace(recipientMatch[0], '').trim();
}
const postalMatch = text.match(/\(کد پستی:\s*([^)]+)\)/);
if (postalMatch) {
- if (!shipping.postalCode) shipping.postalCode = postalMatch[1]?.trim();
+ if (!shipping.postalCode)
+ shipping.postalCode = postalMatch[1]?.trim();
text = text.replace(postalMatch[0], '').trim();
}
- const parts = text.split(/[،,]/).map((p) => p.trim()).filter(Boolean);
+ const parts = text
+ .split(/[،,]/)
+ .map((p) => p.trim())
+ .filter(Boolean);
if (parts.length >= 3) {
if (!shipping.province) shipping.province = parts[0];
if (!shipping.city) shipping.city = parts[1];
- if (!shipping.address) shipping.address = parts.slice(2).join('، ');
+ if (!shipping.address)
+ shipping.address = parts.slice(2).join('، ');
} else if (parts.length === 2) {
if (!shipping.province) shipping.province = parts[0];
if (!shipping.address) shipping.address = parts[1];
@@ -897,25 +906,35 @@ export class AdminService {
customerFullName
).trim();
- const finalTracking = trackingNumber || updatedOrder.trackingNumber || '';
+ const finalTracking =
+ trackingNumber || updatedOrder.trackingNumber || '';
const orderData = {
- orderNumber: updatedOrder.trackingNumber || updatedOrder.id.slice(0, 8),
+ orderNumber:
+ updatedOrder.trackingNumber || updatedOrder.id.slice(0, 8),
amount: Number(updatedOrder.totalAmount || 0).toLocaleString('fa-IR'),
customerName: customerFullName,
customerPhone: user?.mobile || shipping.phone || '',
recipientName: recipientFullName,
recipientPhone: shipping.phone || user?.mobile || '',
- provinceCity: (shipping.province && shipping.city ? `${shipping.province} - ${shipping.city}` : shipping.province || shipping.city || 'ثبت نشده').trim(),
+ provinceCity: (shipping.province && shipping.city
+ ? `${shipping.province} - ${shipping.city}`
+ : shipping.province || shipping.city || 'ثبت نشده'
+ ).trim(),
postalCode: shipping.postalCode || shipping.zipCode || '',
- address: (shipping.address || (typeof rawShipping === 'string' ? rawShipping : '')).trim(),
+ address: (
+ shipping.address ||
+ (typeof rawShipping === 'string' ? rawShipping : '')
+ ).trim(),
trackingCode: finalTracking || 'ارسال با پیک شهری',
shippingTrackingCode: finalTracking || 'ارسال با پیک شهری',
shippingMethod: finalTracking ? 'پست پیشتاز / تیپاکس' : 'پیک شهری',
orderDate: new Date().toLocaleDateString('fa-IR'),
};
- this.smsService.triggerEvent('ORDER_SHIPPED', orderData).catch(() => {});
+ this.smsService
+ .triggerEvent('ORDER_SHIPPED', orderData)
+ .catch(() => {});
} catch (smsErr) {
// Silently catch SMS errors to avoid breaking status update
}
@@ -944,6 +963,12 @@ export class AdminService {
);
}
+ if (order.refundStatus === 'wallet' || order.refundStatus === 'gateway') {
+ throw new BadRequestException(
+ 'برای این سفارش قبلاً استرداد وجه انجام شده است',
+ );
+ }
+
const refundAmount =
customAmount !== undefined && customAmount > 0
? customAmount
@@ -980,6 +1005,9 @@ export class AdminService {
where: { id: order.id },
data: {
status: 'cancelled',
+ refundStatus: 'wallet',
+ refundedAmount: refundAmount,
+ refundMethod: 'wallet',
},
});
});
@@ -1000,11 +1028,15 @@ export class AdminService {
customerPhone: order.user.mobile,
amount: Number(refundAmount || 0).toLocaleString('fa-IR'),
balance: Number(user?.walletBalance || 0).toLocaleString('fa-IR'),
- reason: reason || `استرداد وجه سفارش #${order.trackingNumber || order.id.slice(0, 8)}`,
+ reason:
+ reason ||
+ `استرداد وجه سفارش #${order.trackingNumber || order.id.slice(0, 8)}`,
date: new Date().toLocaleDateString('fa-IR'),
};
- this.smsService.triggerEvent('WALLET_CHARGED', refundData).catch(() => {});
+ this.smsService
+ .triggerEvent('WALLET_CHARGED', refundData)
+ .catch(() => {});
} catch (err) {
// Silently catch SMS errors
}
@@ -1290,8 +1322,14 @@ export class AdminService {
customerName: customerFullName,
customerPhone: updatedUser.mobile,
amount: Number(amount || 0).toLocaleString('fa-IR'),
- balance: Number(updatedUser.walletBalance || 0).toLocaleString('fa-IR'),
- reason: description || (type === 'refund' ? 'استرداد وجه به کیف پول' : 'شارژ کیف پول توسط مدیریت'),
+ balance: Number(updatedUser.walletBalance || 0).toLocaleString(
+ 'fa-IR',
+ ),
+ reason:
+ description ||
+ (type === 'refund'
+ ? 'استرداد وجه به کیف پول'
+ : 'شارژ کیف پول توسط مدیریت'),
date: new Date().toLocaleDateString('fa-IR'),
};
diff --git a/backend/src/admin/dto/product.dto.ts b/backend/src/admin/dto/product.dto.ts
index 4bb38ed..9825b01 100644
--- a/backend/src/admin/dto/product.dto.ts
+++ b/backend/src/admin/dto/product.dto.ts
@@ -304,12 +304,16 @@ export class ProductDto {
@IsArray()
keyHighlights?: string[];
- @ApiPropertyOptional({ description: 'کارتهای شاخص «چرا این محصول؟» (۳ کارت مزایا)' })
+ @ApiPropertyOptional({
+ description: 'کارتهای شاخص «چرا این محصول؟» (۳ کارت مزایا)',
+ })
@IsOptional()
@IsArray()
keyBenefits?: Array<{ icon: string; title: string; description: string }>;
- @ApiPropertyOptional({ description: 'جدول آنالیز و درصد ترکیبات (Analytical Constituents)' })
+ @ApiPropertyOptional({
+ description: 'جدول آنالیز و درصد ترکیبات (Analytical Constituents)',
+ })
@IsOptional()
analysis?: Record;
@@ -327,12 +331,16 @@ export class ProductDto {
@IsOptional()
contraindications?: string[] | string;
- @ApiPropertyOptional({ description: 'نتایج بالینی مورد انتظار (Expected Clinical Results)' })
+ @ApiPropertyOptional({
+ description: 'نتایج بالینی مورد انتظار (Expected Clinical Results)',
+ })
@IsOptional()
@IsArray()
expectedResults?: Array<{ icon: string; text: string }>;
- @ApiPropertyOptional({ description: 'توصیه و نظر پزشک متخصص (Specialist Endorsement)' })
+ @ApiPropertyOptional({
+ description: 'توصیه و نظر پزشک متخصص (Specialist Endorsement)',
+ })
@IsOptional()
specialist?: {
name?: string;
diff --git a/backend/src/common/services/sms-events.constants.ts b/backend/src/common/services/sms-events.constants.ts
index fde587a..09bf0fa 100644
--- a/backend/src/common/services/sms-events.constants.ts
+++ b/backend/src/common/services/sms-events.constants.ts
@@ -27,39 +27,93 @@ export const SMS_EVENT_DEFINITIONS: SmsEventDefinition[] = [
{
event: 'ORDER_PAID',
label: 'پرداخت موفق سفارش (Order Paid)',
- description: 'هنگام پرداخت موفق آنلاین در درگاه بانکی یا تسویه حساب از طریق کیف پول',
+ description:
+ 'هنگام پرداخت موفق آنلاین در درگاه بانکی یا تسویه حساب از طریق کیف پول',
variables: [
- { key: 'orderNumber', label: 'شماره سفارش / فاکتور', sample: 'CN-140308-01' },
+ {
+ key: 'orderNumber',
+ label: 'شماره سفارش / فاکتور',
+ sample: 'CN-140308-01',
+ },
{ key: 'amount', label: 'مبلغ پرداختی (تومان)', sample: '۱,۴۵۰,۰۰۰' },
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
- { key: 'customerPhone', label: 'شماره موبایل خریدار', sample: '09123456789' },
+ {
+ key: 'customerPhone',
+ label: 'شماره موبایل خریدار',
+ sample: '09123456789',
+ },
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
- { key: 'recipientPhone', label: 'تلفن تماس تحویلگیرنده', sample: '09351234567' },
- { key: 'provinceCity', label: 'استان و شهر مقصد', sample: 'تهران - تهران' },
+ {
+ key: 'recipientPhone',
+ label: 'تلفن تماس تحویلگیرنده',
+ sample: '09351234567',
+ },
+ {
+ key: 'provinceCity',
+ label: 'استان و شهر مقصد',
+ sample: 'تهران - تهران',
+ },
{ key: 'postalCode', label: 'کد پستی ۱۰ رقمی', sample: '1967812345' },
- { key: 'address', label: 'نشانی کامل پستی', sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴' },
- { key: 'itemsSummary', label: 'خلاصه اقلام سفارش', sample: 'شیر خشک توله سگ (۲)' },
+ {
+ key: 'address',
+ label: 'نشانی کامل پستی',
+ sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴',
+ },
+ {
+ key: 'itemsSummary',
+ label: 'خلاصه اقلام سفارش',
+ sample: 'شیر خشک توله سگ (۲)',
+ },
{ key: 'itemCount', label: 'تعداد کل اقلام', sample: '۲' },
{ key: 'paymentMethod', label: 'روش پرداخت', sample: 'آنلاین' },
{ key: 'orderDate', label: 'تاریخ ثبت سفارش', sample: '۱۴۰۳/۰۶/۱۵' },
- { key: 'refNumber', label: 'شماره پیگیری پرداخت بانکی', sample: '83920192' },
+ {
+ key: 'refNumber',
+ label: 'شماره پیگیری پرداخت بانکی',
+ sample: '83920192',
+ },
],
},
{
event: 'ORDER_CREATED',
label: 'ثبت سفارش کارت به کارت یا غیرآنلاین',
- description: 'هنگامی که سفارش با روش کارت به کارت در انتظار پرداخت ثبت میشود',
+ description:
+ 'هنگامی که سفارش با روش کارت به کارت در انتظار پرداخت ثبت میشود',
variables: [
- { key: 'orderNumber', label: 'شماره سفارش / فاکتور', sample: 'CN-140308-01' },
+ {
+ key: 'orderNumber',
+ label: 'شماره سفارش / فاکتور',
+ sample: 'CN-140308-01',
+ },
{ key: 'amount', label: 'مبلغ کل سفارش', sample: '۱,۴۵۰,۰۰۰' },
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
- { key: 'customerPhone', label: 'شماره موبایل خریدار', sample: '09123456789' },
+ {
+ key: 'customerPhone',
+ label: 'شماره موبایل خریدار',
+ sample: '09123456789',
+ },
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
- { key: 'recipientPhone', label: 'تلفن تحویلگیرنده', sample: '09351234567' },
- { key: 'provinceCity', label: 'استان و شهر مقصد', sample: 'تهران - تهران' },
+ {
+ key: 'recipientPhone',
+ label: 'تلفن تحویلگیرنده',
+ sample: '09351234567',
+ },
+ {
+ key: 'provinceCity',
+ label: 'استان و شهر مقصد',
+ sample: 'تهران - تهران',
+ },
{ key: 'postalCode', label: 'کد پستی ۱۰ رقمی', sample: '1967812345' },
- { key: 'address', label: 'نشانی پستی', sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴' },
- { key: 'itemsSummary', label: 'خلاصه اقلام سفارش', sample: 'شیر خشک توله سگ (۲)' },
+ {
+ key: 'address',
+ label: 'نشانی پستی',
+ sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴',
+ },
+ {
+ key: 'itemsSummary',
+ label: 'خلاصه اقلام سفارش',
+ sample: 'شیر خشک توله سگ (۲)',
+ },
{ key: 'itemCount', label: 'تعداد کل اقلام', sample: '۲' },
{ key: 'paymentMethod', label: 'روش پرداخت', sample: 'کارت به کارت' },
{ key: 'orderDate', label: 'تاریخ ثبت سفارش', sample: '۱۴۰۳/۰۶/۱۵' },
@@ -71,11 +125,23 @@ export const SMS_EVENT_DEFINITIONS: SmsEventDefinition[] = [
description: 'هنگام تغییر وضعیت سفارش به ارسال شده یا درج کد رهگیری پستی',
variables: [
{ key: 'orderNumber', label: 'شماره سفارش', sample: 'CN-140308-01' },
- { key: 'shippingTrackingCode', label: 'کد رهگیری ۲۴ رقمی پست', sample: '184920394857283948571928' },
- { key: 'trackingCode', label: 'کد رهگیری پستی (مترادف)', sample: '184920394857283948571928' },
+ {
+ key: 'shippingTrackingCode',
+ label: 'کد رهگیری ۲۴ رقمی پست',
+ sample: '184920394857283948571928',
+ },
+ {
+ key: 'trackingCode',
+ label: 'کد رهگیری پستی (مترادف)',
+ sample: '184920394857283948571928',
+ },
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
- { key: 'customerPhone', label: 'شماره همراه مشتری', sample: '09123456789' },
+ {
+ key: 'customerPhone',
+ label: 'شماره همراه مشتری',
+ sample: '09123456789',
+ },
{ key: 'shippingMethod', label: 'روش ارسال', sample: 'پست پیشتاز' },
],
},
@@ -84,9 +150,21 @@ export const SMS_EVENT_DEFINITIONS: SmsEventDefinition[] = [
label: 'درخواست همکاری و عمدهفروشی B2B',
description: 'هنگام ارسال فرم همکاری همکاران و کلینیکهای دامپزشکی',
variables: [
- { key: 'applicantName', label: 'نام متقاضی / فروشگاه', sample: 'پتشاپ آلفا' },
- { key: 'applicantPhone', label: 'شماره همراه متقاضی', sample: '09123456789' },
- { key: 'companyName', label: 'نام شرکت یا مجموعه', sample: 'فروشگاه آلفا' },
+ {
+ key: 'applicantName',
+ label: 'نام متقاضی / فروشگاه',
+ sample: 'پتشاپ آلفا',
+ },
+ {
+ key: 'applicantPhone',
+ label: 'شماره همراه متقاضی',
+ sample: '09123456789',
+ },
+ {
+ key: 'companyName',
+ label: 'نام شرکت یا مجموعه',
+ sample: 'فروشگاه آلفا',
+ },
{ key: 'businessType', label: 'نوع فعالیت', sample: 'پتشاپ' },
{ key: 'date', label: 'تاریخ ثبت درخواست', sample: '۱۴۰۳/۰۶/۱۵' },
],
@@ -97,23 +175,44 @@ export const SMS_EVENT_DEFINITIONS: SmsEventDefinition[] = [
description: 'هنگامی که کاربری در فرم تماس پیامی ارسال میکند',
variables: [
{ key: 'senderName', label: 'نام فرستنده پیام', sample: 'سارا احمدی' },
- { key: 'senderPhone', label: 'شماره همراه فرستنده', sample: '09123456789' },
+ {
+ key: 'senderPhone',
+ label: 'شماره همراه فرستنده',
+ sample: '09123456789',
+ },
{ key: 'email', label: 'ایمیل فرستنده', sample: 'user@example.com' },
{ key: 'subject', label: 'موضوع پیام', sample: 'مشاوره خرید مکمل' },
- { key: 'message', label: 'متن پیام', sample: 'درخواست راهنمایی در مورد دوز کلسیم' },
+ {
+ key: 'message',
+ label: 'متن پیام',
+ sample: 'درخواست راهنمایی در مورد دوز کلسیم',
+ },
{ key: 'date', label: 'تاریخ ارسال', sample: '۱۴۰۳/۰۶/۱۵' },
],
},
{
event: 'WALLET_CHARGED',
label: 'شارژ و افزایش موجودی کیف پول',
- description: 'هنگام افزایش موجودی کیف پول کاربر (درگاه آنلاین، پنل مدیریت، کشبک یا استرداد)',
+ description:
+ 'هنگام افزایش موجودی کیف پول کاربر (درگاه آنلاین، پنل مدیریت، کشبک یا استرداد)',
variables: [
{ key: 'customerName', label: 'نام مشتری', sample: 'علی محمدی' },
{ key: 'amount', label: 'مبلغ شارژ (تومان)', sample: '۵۰,۰۰۰' },
- { key: 'balance', label: 'موجودی جدید کیف پول (تومان)', sample: '۱۵۰,۰۰۰' },
- { key: 'reason', label: 'علت / نوع شارژ', sample: 'شارژ آنلاین / پاداش خرید (Cashback)' },
- { key: 'customerPhone', label: 'شماره همراه مشتری', sample: '09123456789' },
+ {
+ key: 'balance',
+ label: 'موجودی جدید کیف پول (تومان)',
+ sample: '۱۵۰,۰۰۰',
+ },
+ {
+ key: 'reason',
+ label: 'علت / نوع شارژ',
+ sample: 'شارژ آنلاین / پاداش خرید (Cashback)',
+ },
+ {
+ key: 'customerPhone',
+ label: 'شماره همراه مشتری',
+ sample: '09123456789',
+ },
{ key: 'date', label: 'تاریخ شارژ', sample: '۱۴۰۳/۰۶/۱۵' },
],
},
@@ -128,7 +227,8 @@ export const DEFAULT_SMS_RULES: SmsRule[] = [
recipientType: 'customer',
patternId: 508081,
variables: ['customerName', 'orderNumber', 'amount'],
- description: 'ارسال شماره فاکتور و مبلغ سفارش به شماره همراه مشتری بلافاصله پس از پرداخت',
+ description:
+ 'ارسال شماره فاکتور و مبلغ سفارش به شماره همراه مشتری بلافاصله پس از پرداخت',
},
{
id: 'default-order-admin',
diff --git a/backend/src/common/services/sms.service.ts b/backend/src/common/services/sms.service.ts
index 6522449..1326065 100644
--- a/backend/src/common/services/sms.service.ts
+++ b/backend/src/common/services/sms.service.ts
@@ -163,9 +163,7 @@ export class SmsService {
const activeRules = (config.rules || []).filter(
(rule) =>
- rule.enabled &&
- rule.event === event &&
- Number(rule.patternId) > 0,
+ rule.enabled && rule.event === event && Number(rule.patternId) > 0,
);
if (activeRules.length > 0) {
@@ -297,7 +295,8 @@ export class SmsService {
if (!config.username || !config.password) {
return {
success: false,
- message: 'مشخصات نام کاربری و رمز عبور سامانه ملی پیامک تنظیم نشده است.',
+ message:
+ 'مشخصات نام کاربری و رمز عبور سامانه ملی پیامک تنظیم نشده است.',
};
}
@@ -323,7 +322,8 @@ export class SmsService {
if (!phone) {
return {
success: false,
- message: 'شماره گیرنده جهت ارسال تست یافت نشد. لطفاً یک شماره موبایل وارد کنید.',
+ message:
+ 'شماره گیرنده جهت ارسال تست یافت نشد. لطفاً یک شماره موبایل وارد کنید.',
};
}
@@ -496,7 +496,8 @@ export class SmsService {
const patterns: MeliPayamakPattern[] = [];
// MeliPayamak uses and child tags , , ,
// We match both and for backwards/cross compatibility
- const itemRegex = /<(?:Share|Shared)ServiceBody>([\s\S]*?)<\/(?:Share|Shared)ServiceBody>/gi;
+ const itemRegex =
+ /<(?:Share|Shared)ServiceBody>([\s\S]*?)<\/(?:Share|Shared)ServiceBody>/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null) {
@@ -504,7 +505,9 @@ export class SmsService {
const idMatch = block.match(/<(?:BodyID|Id)>(\d+)<\/(?:BodyID|Id)>/i);
const titleMatch = block.match(/([\s\S]*?)<\/Title>/i);
const bodyMatch = block.match(/([\s\S]*?)<\/Body>/i);
- const statusMatch = block.match(/<(?:BodyStatus|Status)>(-?\d+)<\/(?:BodyStatus|Status)>/i);
+ const statusMatch = block.match(
+ /<(?:BodyStatus|Status)>(-?\d+)<\/(?:BodyStatus|Status)>/i,
+ );
if (idMatch) {
const id = parseInt(idMatch[1], 10);
@@ -616,7 +619,9 @@ export class SmsService {
const patterns = await this.getPatterns();
const target = patterns.find((p) => p.id === patternId);
if (!target || !target.body) {
- return args.length > 0 ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}` : `الگو ${patternId}`;
+ return args.length > 0
+ ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}`
+ : `الگو ${patternId}`;
}
let rendered = target.body;
@@ -626,7 +631,9 @@ export class SmsService {
});
return rendered;
} catch {
- return args.length > 0 ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}` : `الگو ${patternId}`;
+ return args.length > 0
+ ? `الگو ${patternId} با مقادیر: ${args.join(' ، ')}`
+ : `الگو ${patternId}`;
}
}
@@ -761,19 +768,34 @@ export class SmsService {
}
// Check legacy default bindings
- if (p.id === config.otpBodyId && !assigned.some((a) => a.includes('OTP'))) {
+ if (
+ p.id === config.otpBodyId &&
+ !assigned.some((a) => a.includes('OTP'))
+ ) {
assigned.push('کد تایید OTP');
}
- if (p.id === config.orderBodyId && !assigned.some((a) => a.includes('سفارش'))) {
+ if (
+ p.id === config.orderBodyId &&
+ !assigned.some((a) => a.includes('سفارش'))
+ ) {
assigned.push('پترن پیشفرض سفارش');
}
- if (p.id === config.shippingBodyId && !assigned.some((a) => a.includes('پست'))) {
+ if (
+ p.id === config.shippingBodyId &&
+ !assigned.some((a) => a.includes('پست'))
+ ) {
assigned.push('پترن پیشفرض پست');
}
- if (p.id === config.b2bBodyId && !assigned.some((a) => a.includes('B2B'))) {
+ if (
+ p.id === config.b2bBodyId &&
+ !assigned.some((a) => a.includes('B2B'))
+ ) {
assigned.push('پترن پیشفرض B2B');
}
- if (p.id === config.petCareBodyId && !assigned.some((a) => a.includes('پت'))) {
+ if (
+ p.id === config.petCareBodyId &&
+ !assigned.some((a) => a.includes('پت'))
+ ) {
assigned.push('پترن پیشفرض پرونده پت');
}
@@ -1029,43 +1051,62 @@ export class SmsService {
});
// Pre-render message text for logging
- void this.renderPatternMessage(options.bodyId, options.args).then((renderedText) => {
- const req = https.request(
- 'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(payload),
+ void this.renderPatternMessage(options.bodyId, options.args).then(
+ (renderedText) => {
+ const req = https.request(
+ 'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payload),
+ },
},
- },
- (res) => {
- let data = '';
- res.on('data', (chunk: Buffer | string) => (data += String(chunk)));
- res.on('end', () => {
- void (async () => {
- try {
- const json = JSON.parse(data) as MeliPayamakResponse;
- const val = json.Value ?? 0;
- if (json && (val > 15 || json.RetStatus === 1)) {
- this.logger.log(
- `[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
- );
- await this.recordLog({
- receptor: options.to,
- type: msgType,
- patternId: options.bodyId,
- args: options.args,
- messageText: renderedText,
- status: 'SUCCESS',
- recId: String(val),
- });
- resolve(true);
- } else {
- const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
- this.logger.error(
- `[SMS Error] Failed sending to ${options.to}. Code: ${val}`,
- );
+ (res) => {
+ let data = '';
+ res.on(
+ 'data',
+ (chunk: Buffer | string) => (data += String(chunk)),
+ );
+ res.on('end', () => {
+ void (async () => {
+ try {
+ const json = JSON.parse(data) as MeliPayamakResponse;
+ const val = json.Value ?? 0;
+ if (json && (val > 15 || json.RetStatus === 1)) {
+ this.logger.log(
+ `[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
+ );
+ await this.recordLog({
+ receptor: options.to,
+ type: msgType,
+ patternId: options.bodyId,
+ args: options.args,
+ messageText: renderedText,
+ status: 'SUCCESS',
+ recId: String(val),
+ });
+ resolve(true);
+ } else {
+ const errMsg = `خطای درگاه ملی پیامک با کد بازگشتی: ${val}`;
+ this.logger.error(
+ `[SMS Error] Failed sending to ${options.to}. Code: ${val}`,
+ );
+ await this.recordLog({
+ receptor: options.to,
+ type: msgType,
+ patternId: options.bodyId,
+ args: options.args,
+ messageText: renderedText,
+ status: 'FAILED',
+ recId: String(val),
+ errorMessage: errMsg,
+ });
+ resolve(false);
+ }
+ } catch (err: unknown) {
+ const msg =
+ err instanceof Error ? err.message : String(err);
await this.recordLog({
receptor: options.to,
type: msgType,
@@ -1073,50 +1114,37 @@ export class SmsService {
args: options.args,
messageText: renderedText,
status: 'FAILED',
- recId: String(val),
- errorMessage: errMsg,
+ errorMessage: `خطای پارس پاسخ سرور: ${data || msg}`,
});
resolve(false);
}
- } catch (err: unknown) {
- const msg = err instanceof Error ? err.message : String(err);
- await this.recordLog({
- receptor: options.to,
- type: msgType,
- patternId: options.bodyId,
- args: options.args,
- messageText: renderedText,
- status: 'FAILED',
- errorMessage: `خطای پارس پاسخ سرور: ${data || msg}`,
- });
- resolve(false);
- }
- })();
- });
- },
- );
+ })();
+ });
+ },
+ );
- req.on('error', (err: Error) => {
- void (async () => {
- this.logger.error(
- `[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
- );
- await this.recordLog({
- receptor: options.to,
- type: msgType,
- patternId: options.bodyId,
- args: options.args,
- messageText: renderedText,
- status: 'FAILED',
- errorMessage: `خطای شبکه: ${err.message}`,
- });
- resolve(false);
- })();
- });
+ req.on('error', (err: Error) => {
+ void (async () => {
+ this.logger.error(
+ `[SMS Exception] MeliPayamak HTTP Error: ${err.message}`,
+ );
+ await this.recordLog({
+ receptor: options.to,
+ type: msgType,
+ patternId: options.bodyId,
+ args: options.args,
+ messageText: renderedText,
+ status: 'FAILED',
+ errorMessage: `خطای شبکه: ${err.message}`,
+ });
+ resolve(false);
+ })();
+ });
- req.write(payload);
- req.end();
- });
+ req.write(payload);
+ req.end();
+ },
+ );
});
}
diff --git a/backend/src/orders/orders.service.ts b/backend/src/orders/orders.service.ts
index c7f1f1c..ce5535e 100644
--- a/backend/src/orders/orders.service.ts
+++ b/backend/src/orders/orders.service.ts
@@ -257,11 +257,9 @@ export class OrdersService {
// Send Order Confirmation SMS for wallet orders
if (user.mobile) {
const orderData = this.buildOrderEventData(createdWalletOrder, user);
- this.smsService
- .triggerEvent('ORDER_PAID', orderData)
- .catch((err) => {
- this.logger.warn(`Failed to trigger wallet order SMS: ${err}`);
- });
+ this.smsService.triggerEvent('ORDER_PAID', orderData).catch((err) => {
+ this.logger.warn(`Failed to trigger wallet order SMS: ${err}`);
+ });
}
return createdWalletOrder;
@@ -324,29 +322,36 @@ export class OrdersService {
shipping = {};
}
}
-
+
// If not parsed as JSON or fields are missing, extract from formatted address string:
// Pattern: "استان، شهر، آدرس پستی (کد پستی: ۱۲۳۴۵۶۷۸۹۰) (گیرنده: نام گیرنده - ۰۹۱۲۰۰۰۰۰۰۰)"
if (!shipping.province || !shipping.address) {
let text = trimmed;
// 1. Extract recipient & phone: (گیرنده: نام گیرنده - ۰۹۱۲۰۰۰۰۰۰۰)
- const recipientMatch = text.match(/\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/);
+ const recipientMatch = text.match(
+ /\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/,
+ );
if (recipientMatch) {
if (!shipping.fullName) shipping.fullName = recipientMatch[1]?.trim();
- if (!shipping.phone && recipientMatch[2]) shipping.phone = recipientMatch[2]?.trim();
+ if (!shipping.phone && recipientMatch[2])
+ shipping.phone = recipientMatch[2]?.trim();
text = text.replace(recipientMatch[0], '').trim();
}
// 2. Extract postal code: (کد پستی: ۱۲۳۴۵۶۷۸۹۰)
const postalMatch = text.match(/\(کد پستی:\s*([^)]+)\)/);
if (postalMatch) {
- if (!shipping.postalCode) shipping.postalCode = postalMatch[1]?.trim();
+ if (!shipping.postalCode)
+ shipping.postalCode = postalMatch[1]?.trim();
text = text.replace(postalMatch[0], '').trim();
}
// 3. Extract province and city: "استان، شهر، بقیه آدرس" or "استان - شهر - بقیه آدرس"
- const parts = text.split(/[،,]/).map((p) => p.trim()).filter(Boolean);
+ const parts = text
+ .split(/[،,]/)
+ .map((p) => p.trim())
+ .filter(Boolean);
if (parts.length >= 3) {
if (!shipping.province) shipping.province = parts[0];
if (!shipping.city) shipping.city = parts[1];
@@ -362,10 +367,7 @@ export class OrdersService {
const items = order.orderItems || [];
const itemsSummary = items
- .map(
- (i: any) =>
- `${i.product?.nameFa || 'محصول'} (${i.quantity})`,
- )
+ .map((i: any) => `${i.product?.nameFa || 'محصول'} (${i.quantity})`)
.join('، ');
const itemCount = items.reduce(
(sum: number, i: any) => sum + (i.quantity || 1),
@@ -384,11 +386,7 @@ export class OrdersService {
customerFullName
).trim();
- const recipientMobile = (
- shipping.phone ||
- user?.mobile ||
- ''
- ).trim();
+ const recipientMobile = (shipping.phone || user?.mobile || '').trim();
const provinceCity = (
shipping.province && shipping.city
@@ -447,14 +445,17 @@ export class OrdersService {
const mobile = updatedOrder.user?.mobile;
const finalTracking = trackingCode || updatedOrder.trackingNumber || '';
if (status === 'shipped' && mobile) {
- const orderData = this.buildOrderEventData(updatedOrder, updatedOrder.user);
+ const orderData = this.buildOrderEventData(
+ updatedOrder,
+ updatedOrder.user,
+ );
orderData.trackingCode = finalTracking || 'ارسال با پیک شهری';
orderData.shippingTrackingCode = finalTracking || 'ارسال با پیک شهری';
- orderData.shippingMethod = finalTracking ? 'پست پیشتاز / تیپاکس' : 'پیک شهری';
+ orderData.shippingMethod = finalTracking
+ ? 'پست پیشتاز / تیپاکس'
+ : 'پیک شهری';
- this.smsService
- .triggerEvent('ORDER_SHIPPED', orderData)
- .catch(() => {});
+ this.smsService.triggerEvent('ORDER_SHIPPED', orderData).catch(() => {});
}
return updatedOrder;
diff --git a/backend/src/payment/payment.controller.ts b/backend/src/payment/payment.controller.ts
index 20b3b4c..9916f90 100644
--- a/backend/src/payment/payment.controller.ts
+++ b/backend/src/payment/payment.controller.ts
@@ -251,9 +251,10 @@ export class PaymentController {
tryReverse?: boolean;
cardNumber?: string;
description?: string;
+ orderId?: string;
},
) {
- return this.zibalService.requestRefund(body);
+ return this.paymentService.handleAdminGatewayRefund(body);
}
@UseGuards(JwtAuthGuard, RolesGuard)
diff --git a/backend/src/payment/payment.service.ts b/backend/src/payment/payment.service.ts
index 365d434..c9570c1 100644
--- a/backend/src/payment/payment.service.ts
+++ b/backend/src/payment/payment.service.ts
@@ -511,22 +511,31 @@ export class PaymentService {
}
if (!shipping.province || !shipping.address) {
let text = trimmed;
- const recipientMatch = text.match(/\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/);
+ const recipientMatch = text.match(
+ /\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/,
+ );
if (recipientMatch) {
- if (!shipping.fullName) shipping.fullName = recipientMatch[1]?.trim();
- if (!shipping.phone && recipientMatch[2]) shipping.phone = recipientMatch[2]?.trim();
+ if (!shipping.fullName)
+ shipping.fullName = recipientMatch[1]?.trim();
+ if (!shipping.phone && recipientMatch[2])
+ shipping.phone = recipientMatch[2]?.trim();
text = text.replace(recipientMatch[0], '').trim();
}
const postalMatch = text.match(/\(کد پستی:\s*([^)]+)\)/);
if (postalMatch) {
- if (!shipping.postalCode) shipping.postalCode = postalMatch[1]?.trim();
+ if (!shipping.postalCode)
+ shipping.postalCode = postalMatch[1]?.trim();
text = text.replace(postalMatch[0], '').trim();
}
- const parts = text.split(/[،,]/).map((p) => p.trim()).filter(Boolean);
+ const parts = text
+ .split(/[،,]/)
+ .map((p) => p.trim())
+ .filter(Boolean);
if (parts.length >= 3) {
if (!shipping.province) shipping.province = parts[0];
if (!shipping.city) shipping.city = parts[1];
- if (!shipping.address) shipping.address = parts.slice(2).join('، ');
+ if (!shipping.address)
+ shipping.address = parts.slice(2).join('، ');
} else if (parts.length === 2) {
if (!shipping.province) shipping.province = parts[0];
if (!shipping.address) shipping.address = parts[1];
@@ -538,10 +547,7 @@ export class PaymentService {
const items = order?.orderItems || [];
const itemsSummary = items
- .map(
- (i: any) =>
- `${i.product?.nameFa || 'محصول'} (${i.quantity})`,
- )
+ .map((i: any) => `${i.product?.nameFa || 'محصول'} (${i.quantity})`)
.join('، ');
const itemCount = items.reduce(
(sum: number, i: any) => sum + (i.quantity || 1),
@@ -562,11 +568,7 @@ export class PaymentService {
customerName
).trim();
- const recipientMobile = (
- shipping.phone ||
- customerMobile ||
- ''
- ).trim();
+ const recipientMobile = (shipping.phone || customerMobile || '').trim();
const provinceCity = (
shipping.province && shipping.city
@@ -601,11 +603,9 @@ export class PaymentService {
orderDate: new Date().toLocaleDateString('fa-IR'),
};
- this.smsService
- .triggerEvent('ORDER_PAID', orderData)
- .catch((err) => {
- this.logger.warn(`Could not trigger SMS event ORDER_PAID: ${err}`);
- });
+ this.smsService.triggerEvent('ORDER_PAID', orderData).catch((err) => {
+ this.logger.warn(`Could not trigger SMS event ORDER_PAID: ${err}`);
+ });
}
// Send SMS notification if wallet top-up
@@ -632,7 +632,9 @@ export class PaymentService {
this.smsService
.triggerEvent('WALLET_CHARGED', topupData)
.catch((err) => {
- this.logger.warn(`Could not trigger SMS event WALLET_CHARGED: ${err}`);
+ this.logger.warn(
+ `Could not trigger SMS event WALLET_CHARGED: ${err}`,
+ );
});
}
} catch (err) {
@@ -1130,7 +1132,9 @@ export class PaymentService {
this.smsService
.triggerEvent('WALLET_CHARGED', topupData)
.catch((err) => {
- this.logger.warn(`Could not trigger SMS event WALLET_CHARGED: ${err}`);
+ this.logger.warn(
+ `Could not trigger SMS event WALLET_CHARGED: ${err}`,
+ );
});
}
} catch (err) {
@@ -1176,4 +1180,99 @@ export class PaymentService {
async checkGatewayHealth() {
return this.zibalService.checkHealth();
}
+
+ /**
+ * 12. Admin: Process Gateway Refund (Zibal) with Wallet Balance Deduction if previously refunded to wallet
+ */
+ async handleAdminGatewayRefund(options: {
+ trackId: string | number;
+ amount?: number; // Rial
+ tryReverse?: boolean;
+ cardNumber?: string;
+ description?: string;
+ orderId?: string;
+ }) {
+ // 1. If orderId is not passed, attempt to find by trackId
+ let order = options.orderId
+ ? await this.prisma.order.findUnique({
+ where: { id: options.orderId },
+ include: { user: true },
+ })
+ : null;
+
+ if (!order && options.trackId) {
+ const tx = await this.prisma.paymentTransaction.findUnique({
+ where: { trackId: String(options.trackId) },
+ include: { order: { include: { user: true } } },
+ });
+ if (tx?.order) {
+ order = tx.order;
+ }
+ }
+
+ if (order && order.refundStatus === 'gateway') {
+ throw new BadRequestException(
+ 'برای این سفارش قبلاً استرداد درگاه بانکی انجام شده است',
+ );
+ }
+
+ // 2. Execute refund via Zibal
+ const zibalRes = await this.zibalService.requestRefund({
+ trackId: options.trackId,
+ amount: options.amount,
+ tryReverse: options.tryReverse,
+ cardNumber: options.cardNumber,
+ description: options.description,
+ });
+
+ const isSuccess =
+ zibalRes?.result === 1 ||
+ zibalRes?.status === 1 ||
+ zibalRes?.result === 100 ||
+ zibalRes?.success === true;
+
+ if (isSuccess && order) {
+ const refundAmountTomans = options.amount
+ ? Math.round(Number(options.amount) / 10)
+ : Number(order.totalAmount || 0);
+
+ await this.prisma.$transaction(async (tx) => {
+ // If order was previously refunded to wallet, deduct that amount from user's wallet!
+ if (order.refundStatus === 'wallet' && order.userId) {
+ const deductionAmount = Number(
+ order.refundedAmount || refundAmountTomans,
+ );
+ await tx.user.update({
+ where: { id: order.userId },
+ data: {
+ walletBalance: { decrement: deductionAmount },
+ },
+ });
+
+ await tx.walletTransaction.create({
+ data: {
+ userId: order.userId,
+ amount: deductionAmount,
+ type: 'withdraw',
+ status: 'completed',
+ description: `کسر موجودی کیف پول به علت استرداد بانکی سفارش #${order.trackingNumber || order.id.slice(0, 8)} از طریق زیبال`,
+ },
+ });
+ }
+
+ // Update order refund status to gateway
+ await tx.order.update({
+ where: { id: order.id },
+ data: {
+ status: 'cancelled',
+ refundStatus: 'gateway',
+ refundedAmount: refundAmountTomans,
+ refundMethod: 'zibal',
+ },
+ });
+ });
+ }
+
+ return zibalRes;
+ }
}
diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts
index a6a0e87..4696b71 100644
--- a/backend/src/settings/settings.controller.ts
+++ b/backend/src/settings/settings.controller.ts
@@ -223,7 +223,10 @@ export class SettingsController {
@Roles('Admin')
@ApiBearerAuth()
@Get('sms/events')
- @ApiOperation({ summary: 'دریافت لیست تمامی رویدادهای سیستم و متغیرهای داینامیک قابل استفاده در پترنها' })
+ @ApiOperation({
+ summary:
+ 'دریافت لیست تمامی رویدادهای سیستم و متغیرهای داینامیک قابل استفاده در پترنها',
+ })
getSmsEvents() {
return this.settingsService.getSmsEvents();
}
@@ -232,11 +235,10 @@ export class SettingsController {
@Roles('Admin')
@ApiBearerAuth()
@Post('sms/test-rule')
- @ApiOperation({ summary: 'تست ارسال پیامک برای یک سناریوی تعریفشده با دیتای تستی' })
- testSmsRule(
- @Body('rule') rule: any,
- @Body('testPhone') testPhone?: string,
- ) {
+ @ApiOperation({
+ summary: 'تست ارسال پیامک برای یک سناریوی تعریفشده با دیتای تستی',
+ })
+ testSmsRule(@Body('rule') rule: any, @Body('testPhone') testPhone?: string) {
return this.settingsService.testSmsRule(rule, testPhone);
}
diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts
index a8f9f4c..9bd3675 100644
--- a/backend/src/settings/settings.service.ts
+++ b/backend/src/settings/settings.service.ts
@@ -608,8 +608,12 @@ export class SettingsService implements OnModuleInit {
updates['ogImageUrl'] = String(obj.ogImageUrl);
}
if ('googleSiteVerification' in obj) {
- updates['googleSiteVerification'] = String(obj.googleSiteVerification ?? '');
- updates['google_site_verification'] = String(obj.googleSiteVerification ?? '');
+ updates['googleSiteVerification'] = String(
+ obj.googleSiteVerification ?? '',
+ );
+ updates['google_site_verification'] = String(
+ obj.googleSiteVerification ?? '',
+ );
}
if ('googleAnalyticsId' in obj) {
updates['googleAnalyticsId'] = String(obj.googleAnalyticsId ?? '');
@@ -620,10 +624,14 @@ export class SettingsService implements OnModuleInit {
updates['gtm_id'] = String(obj.googleTagManagerId ?? '');
}
if ('enableGoogleAnalytics' in obj) {
- updates['enableGoogleAnalytics'] = String(Boolean(obj.enableGoogleAnalytics));
+ updates['enableGoogleAnalytics'] = String(
+ Boolean(obj.enableGoogleAnalytics),
+ );
}
if ('enableGoogleTagManager' in obj) {
- updates['enableGoogleTagManager'] = String(Boolean(obj.enableGoogleTagManager));
+ updates['enableGoogleTagManager'] = String(
+ Boolean(obj.enableGoogleTagManager),
+ );
}
if ('clarityProjectId' in obj) {
updates['clarityProjectId'] = String(obj.clarityProjectId ?? '');
@@ -659,7 +667,7 @@ export class SettingsService implements OnModuleInit {
return this.smsService.testSms(targetPhone, patternId, args);
}
- async getSmsEvents() {
+ getSmsEvents() {
return this.smsService.getEventDefinitions();
}
diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts
index 1e2d06d..b567a8a 100644
--- a/backend/src/users/users.service.ts
+++ b/backend/src/users/users.service.ts
@@ -309,12 +309,16 @@ export class UsersService {
customerName: customerFullName,
customerPhone: updatedUser.mobile,
amount: Number(amount || 0).toLocaleString('fa-IR'),
- balance: Number(updatedUser.walletBalance || 0).toLocaleString('fa-IR'),
+ balance: Number(updatedUser.walletBalance || 0).toLocaleString(
+ 'fa-IR',
+ ),
reason: 'افزایش موجودی کیف پول',
date: new Date().toLocaleDateString('fa-IR'),
};
- this.smsService.triggerEvent('WALLET_CHARGED', topupData).catch(() => {});
+ this.smsService
+ .triggerEvent('WALLET_CHARGED', topupData)
+ .catch(() => {});
} catch (err) {
// Silently catch SMS error
}