feat(backend): add wallet refund transactions and enhance SMS automation rules

This commit is contained in:
parsa aghaei 2026-09-05 15:16:04 +03:30
parent e15c25dae1
commit 5e3e3958c5
12 changed files with 490 additions and 197 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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'),
};

View File

@ -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<string, string>;
@ -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;

View File

@ -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',

View File

@ -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 <ShareServiceBody> and child tags <BodyID>, <Title>, <Body>, <BodyStatus>
// We match both <ShareServiceBody> and <SharedServiceBody> 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(/<Title>([\s\S]*?)<\/Title>/i);
const bodyMatch = block.match(/<Body>([\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();
},
);
});
}

View File

@ -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;

View File

@ -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)

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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
}