diff --git a/controllers/application/chatRooms/chatRoomController.js b/controllers/application/chatRooms/chatRoomController.js index 13cfaa1..56a7e2f 100644 --- a/controllers/application/chatRooms/chatRoomController.js +++ b/controllers/application/chatRooms/chatRoomController.js @@ -371,7 +371,7 @@ const sendRoomMessage = async (req, res, next) => { } const textContent = content != null ? String(content).trim() : '' - const uploaded = req.files?.file + const uploaded = req.file const resolvedFileType = fileType || undefined if (!textContent && !uploaded) { @@ -409,22 +409,16 @@ const sendRoomMessage = async (req, res, next) => { let fileUrl = null if (uploaded) { - const uploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages') - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) - } - // اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (uploaded.name) گرفته نمی‌شه — فقط پسوندش - const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}` - const filePath = path.join(uploadDir, uniqueFileName) - await fs.move(uploaded.path, filePath) - if (resolvedFileType === 'image' || (uploaded.type || '').startsWith('image/')) { - await optimizeImageFile(filePath) - } else if (resolvedFileType === 'video' || (uploaded.type || '').startsWith('video/')) { - optimizeVideoFaststart(filePath).catch((err) => { + // multer (roomMessageUpload توی routes) از قبل فایل رو مستقیماً روی پوشه‌ی + // نهایی نوشته — دیگه نیازی به fs.move جداگانه نیست + if (resolvedFileType === 'image' || (uploaded.mimetype || '').startsWith('image/')) { + await optimizeImageFile(uploaded.path) + } else if (resolvedFileType === 'video' || (uploaded.mimetype || '').startsWith('video/')) { + optimizeVideoFaststart(uploaded.path).catch((err) => { console.error('[chatRoomController] video optimize background error:', err.message) }) } - fileUrl = `/chat-rooms/messages/${uniqueFileName}` + fileUrl = `/chat-rooms/messages/${uploaded.filename}` } room.lastActivityAt = new Date() diff --git a/controllers/application/payment/paymentController.js b/controllers/application/payment/paymentController.js index c7bde36..7b716b6 100644 --- a/controllers/application/payment/paymentController.js +++ b/controllers/application/payment/paymentController.js @@ -14,7 +14,7 @@ const { consumeFreeCreation, freeCreationLimitResponse } = require('../../../utils/freeCreationLimit') -const { canUseFreeBillboard, consumeFreeBillboard } = require('../../../utils/billboardFreeLimit') +const { tryConsumeFreeBillboard } = require('../../../utils/billboardFreeLimit') const { PROJECT_ACCEPT_RATE, PROJECT_COMPLETION_COMMISSION_RATE, @@ -64,6 +64,18 @@ const initiatePayment = async (req, res) => { const itemName = req.body.item_name // Get item name from request body const price = await getPriceFromDatabase(itemName) // Get price from database const projectId = req.body.projectId // Get project ID from request body + + // IDOR: قبلاً هیچ‌جا چک نمی‌شد که این پروژه واقعاً متعلق به همین کاربرِ + // احرازهویت‌شده‌ست — یه projectId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت کنه + // ولی پروژه‌ی یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه + const projectForOwnerCheck = await ProjectModel.findById(projectId).select('creator_id') + if (!projectForOwnerCheck) { + return res.status(404).json({ message: 'پروژه یافت نشد' }) + } + if (String(projectForOwnerCheck.creator_id) !== String(userId)) { + return res.status(403).json({ message: 'دسترسی به این پروژه ندارید' }) + } + // Save project ID for later use in callback URL req.session.projectId = projectId const item = await TypeAndPriceModel.findOne({ name: itemName }) @@ -641,15 +653,24 @@ const initiateAdvertisingPayment = async (req, res) => { const advertisingId = req.body.advertisingId // Get project ID from request body const showDiscount = req.body.showDiscount + // IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ + // احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت + // کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه + const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id') + if (!advertisingForOwnerCheck) { + return res.status(404).json({ message: 'بیلبورد یافت نشد' }) + } + if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) { + return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' }) + } + // Save project ID for later use in callback URL req.session.advertisingId = advertisingId const item = await AdvertisingTypeModel.findOne({ name: itemName }) - const user = await UserModel.findById(userId) if (item.name === 'free' && item.price === 0) { - if (canUseFreeBillboard(user)) { - await consumeFreeBillboard(user) - await user.save() + const freeResult = await tryConsumeFreeBillboard(UserModel, userId) + if (freeResult.ok) { const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -660,7 +681,7 @@ const initiateAdvertisingPayment = async (req, res) => { await project.save() res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' }) } else { - res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' }) + res.status(403).json({ message: freeResult.message }) } } else { const response = await axios.post( @@ -796,12 +817,10 @@ const republishAdvertisingPayment = async (req, res) => { // Save project ID for later use in callback URL req.session.advertisingId = advertisingId const item = await AdvertisingTypeModel.findOne({ name: itemName }) - const user = await UserModel.findById(userId) if (item.name === 'free' && item.price === 0) { - if (canUseFreeBillboard(user)) { - await consumeFreeBillboard(user) - await user.save() + const freeResult = await tryConsumeFreeBillboard(UserModel, userId) + if (freeResult.ok) { const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -812,7 +831,7 @@ const republishAdvertisingPayment = async (req, res) => { await project.save() res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' }) } else { - res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' }) + res.status(403).json({ message: freeResult.message }) } } else { const response = await axios.post( diff --git a/controllers/application/payment/paymentControllerWeb.js b/controllers/application/payment/paymentControllerWeb.js index b0d1c6f..2fc88c5 100644 --- a/controllers/application/payment/paymentControllerWeb.js +++ b/controllers/application/payment/paymentControllerWeb.js @@ -9,7 +9,7 @@ const NotificationModel = require('../../../models/NotificationModel') const UserModel = require('../../../models/UserModel') const AdvertisingTypeModel = require('../../../models/AdvertisingTypeModel') const AdvertisingModel = require('../../../models/AdvertisingModel') -const { canUseFreeBillboard, consumeFreeBillboard } = require('../../../utils/billboardFreeLimit') +const { tryConsumeFreeBillboard } = require('../../../utils/billboardFreeLimit') const OfferTypeModel = require('../../../models/OfferTypeModel') const OfferModel = require('../../../models/OfferModel') const { default: axios } = require('axios') @@ -92,6 +92,17 @@ const initiatePaymentWeb = async (req, res) => { }) } + // IDOR: قبلاً هیچ‌جا چک نمی‌شد که این پروژه واقعاً متعلق به همین کاربرِ + // احرازهویت‌شده‌ست — یه projectId دلخواه (مثلاً از یه لینکِ ساختگی) کافی بود تا + // کاربرِ دیگه‌ای پرداخت کنه ولی پروژه‌ی یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه + const projectForOwnerCheck = await ProjectModel.findById(projectId).select('creator_id') + if (!projectForOwnerCheck) { + return res.status(404).json({ error: true, message: 'پروژه یافت نشد' }) + } + if (String(projectForOwnerCheck.creator_id) !== String(userId)) { + return res.status(403).json({ error: true, message: 'دسترسی به این پروژه ندارید' }) + } + const price = Number(item.price) || 0 const user = await UserModel.findById(userId) if (!user) { @@ -566,16 +577,25 @@ const initiateAdvertisingPaymentWeb = async (req, res) => { const advertisingId = req.body.advertisingId // Get project ID from request body const showDiscount = req.body.showDiscount + // IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ + // احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت + // کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه (رایگان بود، فقط + // با مصرفِ سهمیه‌ی رایگانِ همون کاربر هم ممکن بود) + const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id') + if (!advertisingForOwnerCheck) { + return res.status(404).json({ message: 'بیلبورد یافت نشد' }) + } + if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) { + return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' }) + } + // Save project ID for later use in callback URL req.session.advertisingId = advertisingId const item = await AdvertisingTypeModel.findOne({ name: itemName }) - const user = await UserModel.findById(userId) if (item.name === 'free' && item.price === 0) { - if (canUseFreeBillboard(user)) { - await consumeFreeBillboard(user) - // updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb - await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } }) + const freeResult = await tryConsumeFreeBillboard(UserModel, userId) + if (freeResult.ok) { const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -586,7 +606,7 @@ const initiateAdvertisingPaymentWeb = async (req, res) => { await project.save() res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' }) } else { - res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' }) + res.status(403).json({ message: freeResult.message }) } } else { const response = await axios.post( @@ -721,16 +741,25 @@ const republishAdvertisingPaymentWeb = async (req, res) => { const price = await getAdvertisingPriceFromDatabase(itemName) // Get price from database const advertisingId = req.body.advertisingId // Get project ID from request body const showDiscount = req.body.showDiscount + // IDOR: قبلاً هیچ‌جا چک نمی‌شد که این بیلبورد واقعاً متعلق به همین کاربرِ + // احرازهویت‌شده‌ست — یه advertisingId دلخواه کافی بود تا کاربرِ دیگه‌ای پرداخت + // کنه ولی بیلبوردِ یه کاربرِ کاملاً متفاوت تایید/پرداخت‌شده بشه (رایگان بود، فقط + // با مصرفِ سهمیه‌ی رایگانِ همون کاربر هم ممکن بود) + const advertisingForOwnerCheck = await AdvertisingModel.findById(advertisingId).select('creator_id') + if (!advertisingForOwnerCheck) { + return res.status(404).json({ message: 'بیلبورد یافت نشد' }) + } + if (String(advertisingForOwnerCheck.creator_id) !== String(userId)) { + return res.status(403).json({ message: 'دسترسی به این بیلبورد ندارید' }) + } + // Save project ID for later use in callback URL req.session.advertisingId = advertisingId const item = await AdvertisingTypeModel.findOne({ name: itemName }) - const user = await UserModel.findById(userId) if (item.name === 'free' && item.price === 0) { - if (canUseFreeBillboard(user)) { - await consumeFreeBillboard(user) - // updateOne + $inc به‌جای user.save() — رجوع کنید به توضیح مشابه در initiateOfferPaymentWeb - await UserModel.updateOne({ _id: userId }, { $inc: { free_billboard_count: 1 } }) + const freeResult = await tryConsumeFreeBillboard(UserModel, userId) + if (freeResult.ok) { const project = await AdvertisingModel.findById(advertisingId) if (!project) { res.status(404).send('Advertising not found') @@ -741,7 +770,7 @@ const republishAdvertisingPaymentWeb = async (req, res) => { await project.save() res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' }) } else { - res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' }) + res.status(403).json({ message: freeResult.message }) } } else { const response = await axios.post( diff --git a/controllers/application/register/completeSocialSignupController.js b/controllers/application/register/completeSocialSignupController.js index 3e51bc8..ef4c9e5 100644 --- a/controllers/application/register/completeSocialSignupController.js +++ b/controllers/application/register/completeSocialSignupController.js @@ -69,6 +69,7 @@ const completeSocialSignup = async (req, res, next) => { ) return res.status(422).json({ error: true, + code: 'duplicate_username', field: 'user_name', message: 'نام کاربری تکراری است', suggestions diff --git a/controllers/application/register/usernameController.js b/controllers/application/register/usernameController.js index 92af58f..41595a2 100644 --- a/controllers/application/register/usernameController.js +++ b/controllers/application/register/usernameController.js @@ -59,6 +59,7 @@ const setUserName = async (req, res, next) => { ) return res.status(422).json({ error: true, + code: 'duplicate_username', message: 'نام کاربری تکراری است', suggestions }) @@ -113,6 +114,7 @@ const updateUserName = async (req, res, next) => { ) return res.status(422).json({ error: true, + code: 'duplicate_username', message: 'نام کاربری تکراری است', suggestions }) diff --git a/index.js b/index.js index c07e317..5fe0574 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ try { console.warn('[index] postVideoProcessing unavailable:', error.message); } const http = require('http').createServer(app); +const multer = require('multer'); const cors = require('cors'); const jwt = require('jsonwebtoken'); const moment = require('moment-jalaali'); @@ -417,7 +418,31 @@ app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => { }) }) -app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => { +// قبلاً express-form-data به‌صورتِ global بدنه‌ی هر درخواستِ multipart رو می‌خوند و +// req.files رو پر می‌کرد؛ وقتی اون middleware (به‌خاطرِ تداخل با multer توی روت‌های +// دیگه مثل posts) حذف شد، این روت که مستقیماً به همون req.files تکیه داشت بدونِ هیچ +// parserِ جایگزینی موند — یعنی req.files همیشه undefined بود و هر ارسالِ عکس/ویدیو/ +// فایل/ویس توی چت (وب و اندروید، چون هر دو همین یه اندپوینتِ مشترک رو صدا می‌زنن) با +// «Server error» شکست می‌خورد. همون الگوی multer.diskStorage که posts استفاده می‌کنه، +// اینجا هم (مستقیماً روی پوشه‌ی نهایی، بدونِ نیاز به fs.move جداگانه) وصل شد +const chatFileUploadDir = path.join(__dirname, '../storage/messages') +if (!fs.existsSync(chatFileUploadDir)) { + fs.mkdirSync(chatFileUploadDir, { recursive: true }) +} +const chatFileUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, chatFileUploadDir), + filename: (req, file, cb) => { + // اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (file.originalname) گرفته نمی‌شه — + // فقط پسوندش؛ وگرنه یه originalname مثل ../../../../etc/passwd می‌تونست از + // uploadDir بیرون بزنه (path traversal / نوشتنِ فایلِ دلخواه روی سرور) + cb(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname || '')}`) + } + }), + limits: { fileSize: 200 * 1024 * 1024 } +}).single('file') + +app.post('/api/v1/chat/file', [blockCheck, chatFileUpload, chatPeerBlock], async (req, res) => { try { const senderId = String(req.user._id) const { receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body @@ -448,27 +473,17 @@ app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => { if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) { return res.status(422).json({ error: 'View once is only for image, video, and voice' }) } - const { file } = req.files + const file = req.file let fileUrl = null if (file) { - const uploadDir = path.join(__dirname, '../storage/messages') - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) - } - // اسمِ فایل هیچ‌وقت مستقیم از ورودیِ کاربر (file.name) گرفته نمی‌شه — فقط - // پسوندش؛ وگرنه یه filename مثل ../../../../etc/passwd می‌تونست از uploadDir - // بیرون بزنه (path traversal / نوشتنِ فایلِ دلخواه روی سرور) - const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}` - const filePath = path.join(uploadDir, uniqueFileName) - await fs.move(file.path, filePath) - if (resolvedFileType === 'image' || (file.type || '').startsWith('image/')) { - await optimizeImageFile(filePath) - } else if (resolvedFileType === 'video' || (file.type || '').startsWith('video/')) { - optimizeVideoFaststart(filePath).catch((err) => { + if (resolvedFileType === 'image' || (file.mimetype || '').startsWith('image/')) { + await optimizeImageFile(file.path) + } else if (resolvedFileType === 'video' || (file.mimetype || '').startsWith('video/')) { + optimizeVideoFaststart(file.path).catch((err) => { console.error('[chat/file] video optimize background error:', err.message) }) } - fileUrl = `/messages/${uniqueFileName}` + fileUrl = `/messages/${file.filename}` } const newMessage = new MessageModel({ diff --git a/routes/application/chat-rooms/index.js b/routes/application/chat-rooms/index.js index 568d598..4cf8c6d 100644 --- a/routes/application/chat-rooms/index.js +++ b/routes/application/chat-rooms/index.js @@ -1,4 +1,8 @@ const express = require('express') +const path = require('path') +const fs = require('fs') +const crypto = require('crypto') +const multer = require('multer') const { listRooms, createRoom, @@ -15,6 +19,23 @@ const requireSubUserPermission = require('../../../middlewares/requireSubUserPer const router = express.Router() +// همون دلیلِ /api/v1/chat/file توی index.js: این روت قبلاً به‌جای multer به +// express-form-data (که بعداً global حذف شد) تکیه داشت، پس req.files همیشه +// undefined بود و هر ارسالِ فایل/عکس/ویس/ویدیو توی چتِ روم هم شکست می‌خورد +const roomMessageUploadDir = path.join(__dirname, '../../../storage/chat-rooms/messages') +if (!fs.existsSync(roomMessageUploadDir)) { + fs.mkdirSync(roomMessageUploadDir, { recursive: true }) +} +const roomMessageUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, roomMessageUploadDir), + filename: (req, file, cb) => { + cb(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname || '')}`) + } + }), + limits: { fileSize: 200 * 1024 * 1024 } +}).single('file') + router.get('/', listRooms) router.post('/', createRoom) router.get('/:roomId', getRoomDetail) @@ -23,7 +44,7 @@ router.post('/:roomId/members', addRoomMembers) router.get('/:roomId/e2ee-keys', getRoomE2eeKeys) router.put('/:roomId/e2ee-keys', putRoomE2eeKeys) router.get('/:roomId/messages', getRoomMessages) -router.post('/:roomId/messages', requireSubUserPermission('reply_dm'), sendRoomMessage) +router.post('/:roomId/messages', roomMessageUpload, requireSubUserPermission('reply_dm'), sendRoomMessage) router.post('/:roomId/messages/reaction', reactToRoomMessage) module.exports = router diff --git a/utils/billboardFreeLimit.js b/utils/billboardFreeLimit.js index e9256de..82b6924 100644 --- a/utils/billboardFreeLimit.js +++ b/utils/billboardFreeLimit.js @@ -9,16 +9,28 @@ function canUseFreeBillboard (user) { return used < BILLBOARD_FREE_LIMIT } -async function consumeFreeBillboard (user) { - if (!canUseFreeBillboard(user)) { +/** + * نسخه‌ی atomic — چک (free_billboard_count < سقف) و افزایش توی یه عملیاتِ واحدِ + * MongoDB (findOneAndUpdate با شرط توی query) انجام می‌شه، نه در دو مرحله‌ی جدا + * (قبلاً canUseFreeBillboard روی یه user از قبل fetch‌شده چک می‌شد و بعد جداگانه + * $inc می‌خورد) — وگرنه دو درخواستِ هم‌زمان (دابل‌کلیکِ «تلاشِ دوباره» روی صفحه‌ی + * پرداختِ ناموفق، که تا امروز گاردِ submitting هم نداشت) هر دو می‌تونستن همون + * user رو با free_billboard_count زیرِ سقف بخونن و هر دو از سقف رد بشن + */ +async function tryConsumeFreeBillboard (UserModel, userId) { + const updated = await UserModel.findOneAndUpdate( + { _id: userId, free_billboard_count: { $lt: BILLBOARD_FREE_LIMIT } }, + { $inc: { free_billboard_count: 1 } }, + { new: true } + ) + if (!updated) { return { ok: false, message: `سقف ${BILLBOARD_FREE_LIMIT} بیلبورد رایگان به پایان رسیده است.` } } - user.free_billboard_count = (user.free_billboard_count || 0) + 1 - return { ok: true } + return { ok: true, user: updated } } module.exports = { BILLBOARD_FREE_LIMIT, canUseFreeBillboard, - consumeFreeBillboard + tryConsumeFreeBillboard } diff --git a/utils/walletSplit.js b/utils/walletSplit.js index c5d61c3..6ca5bb7 100644 --- a/utils/walletSplit.js +++ b/utils/walletSplit.js @@ -13,6 +13,13 @@ const { getBucketBalance } = require('../controllers/application/wallet/walletCo * این‌طوری اگه کاربر پرداختِ درگاه رو نصفه ول کنه، هیچ مبلغی از کیف پول کم نشده که * لازم باشه برگردونده بشه */ +// زرین‌پال حداقل مبلغِ درگاه رو ۱۵٬۰۰۰ ریال (۱٬۵۰۰ تومان) می‌خواد — اگه بعدِ کسرِ +// کیف‌پول فقط یه باقی‌مونده‌ی مثبتِ کوچیک‌تر از این بمونه، کلِ درخواستِ initiate به +// زرین‌پال با خطای 422 رد می‌شه (نه فقط همون بخشِ کوچیک) — این دقیقاً همون چیزی بود +// که باعث می‌شد «ثبت درخواست» (و هر مسیرِ دیگه‌ای که از این تابع استفاده می‌کنه) با +// بعضی ترکیب‌های قیمت/موجودیِ کیف‌پول روی سرور ارور بده +const ZARINPAL_MIN_GATEWAY_TOMAN = 1500 + async function resolveWalletSplit (userId, amount, useWallet) { if (!useWallet) { return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false } @@ -21,8 +28,25 @@ async function resolveWalletSplit (userId, amount, useWallet) { if (!balance || balance <= 0) { return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false } } - const walletAmount = Math.min(balance, amount) - const gatewayAmount = amount - walletAmount + + let walletAmount = Math.min(balance, amount) + let gatewayAmount = amount - walletAmount + + if (gatewayAmount > 0 && gatewayAmount < ZARINPAL_MIN_GATEWAY_TOMAN) { + const neededWalletAmount = amount - ZARINPAL_MIN_GATEWAY_TOMAN + if (neededWalletAmount >= 0 && balance >= neededWalletAmount) { + // از کیف‌پول کمتر استفاده کن تا باقی‌مونده دقیقاً به حداقلِ مجازِ درگاه برسه + walletAmount = neededWalletAmount + gatewayAmount = ZARINPAL_MIN_GATEWAY_TOMAN + } else { + // یا کلِ مبلغ از حداقلِ درگاه کمتره، یا کیف‌پول برای رسوندنِ باقی‌مونده به اون + // حداقل کافی نیست — اصلاً از کیف‌پول استفاده نکن، همه چیز از درگاه. این‌طوری + // حداقل یه خطای واضحِ «حداقل مبلغ» از خودِ زرین‌پال می‌گیریم، نه یه 422ِ گنگ + walletAmount = 0 + gatewayAmount = amount + } + } + return { walletAmount, gatewayAmount, fullyCovered: gatewayAmount <= 0 } }