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