This commit is contained in:
payacom
2026-09-24 21:00:07 +03:30
parent 1954b2661d
commit 7f0ddfde70
113 changed files with 570 additions and 5049 deletions

View File

@@ -5,7 +5,7 @@ const GiftModel = require('../../../models/GiftModel')
const UserModel = require('../../../models/UserModel')
const PaymentModel = require('../../../models/PaymentModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const { getBucketBalance } = require('../wallet/walletController')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const GIFT_MIN_AMOUNT = 2000
@@ -39,10 +39,22 @@ async function validateGiftTarget (senderId, receiverId, amount) {
const GIFT_USER_SELECT = 'profile_image user_level first_name last_name user_name is_verified verify_badge'
async function creditGiftReceiver (receiverId, giftId, giftAmount) {
await WalletTransactionModel.create({
user: receiverId,
bucket: 'gift',
type: 'credit',
amount: Math.round(giftAmount * (1 - GIFT_COMMISSION_RATE)),
referenceType: 'payment',
referenceId: giftId,
description: 'هدیه دریافتی از کاربر دیگر (پس از کسر ۱۰٪ کارمزد)'
})
}
const initiateGiftPayment = async (req, res, next) => {
try {
const senderId = req.user._id
const { receiverId, amount, message } = req.body
const { receiverId, amount, message, platform, useWallet } = req.body
if (!receiverId) {
return res.status(400).json({ error: true, message: 'کاربر گیرنده مشخص نشده است' })
@@ -64,18 +76,43 @@ const initiateGiftPayment = async (req, res, next) => {
return res.status(403).json({ error: true, message: 'این کاربر دریافت هدیه را غیرفعال کرده است' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(senderId, giftAmount, useWallet)
if (fullyCovered) {
await debitWalletForPurchase(senderId, walletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const gift = await GiftModel.create({
sender: senderId,
receiver: receiverId,
amount: giftAmount,
message: message || null,
paymentMethod: 'wallet'
})
await creditGiftReceiver(receiverId, gift._id, giftAmount)
return res.json({
paidFromWallet: true,
gift: { _id: gift._id, amount: gift.amount, message: gift.message }
})
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const callbackUrl = `${callbackBase}/gifts/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&message=${encodeURIComponent(message || '')}`
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const callbackUrl = `${callbackBase}/gifts/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&walletAmount=${walletAmount}&message=${encodeURIComponent(message || '')}${platformSuffix}`
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(giftAmount),
amount: toRial(gatewayAmount),
description: `هدیه ویژه به ${receiver.first_name || receiver.user_name}`,
callback_url: callbackUrl
},
@@ -98,19 +135,25 @@ const initiateGiftPayment = async (req, res, next) => {
}
const handleGiftPaymentCallback = async (req, res) => {
const { Authority, Status, senderId, receiverId, amount, message } = req.query
const failRedirect = `${process.env.APP_SITE}/gift/payment/failed${receiverId ? `?receiverId=${receiverId}` : ''}`
const { Authority, Status, senderId, receiverId, amount, walletAmount, message, platform } = req.query
const isAndroid = platform === 'android'
const failRedirect = isAndroid
? `modstagram://FailedPay?type=gift${receiverId ? `&receiverId=${receiverId}` : ''}`
: `${process.env.APP_SITE}/gift/payment/failed${receiverId ? `?receiverId=${receiverId}` : ''}`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const giftAmount = Number(amount)
const usedWalletAmount = Number(walletAmount) || 0
const gatewayAmount = giftAmount - usedWalletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(giftAmount)
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -120,8 +163,15 @@ const handleGiftPaymentCallback = async (req, res) => {
return res.redirect(failRedirect)
}
// پرداخت درگاه قطعاً موفق شد — همین حالا سهمِ کیف پول هم کسر می‌شه (نگاه کن به
// utils/walletSplit.js)
await debitWalletForPurchase(senderId, usedWalletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const payment = await PaymentModel.create({
amount: giftAmount,
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: senderId,
@@ -133,22 +183,20 @@ const handleGiftPaymentCallback = async (req, res) => {
receiver: receiverId,
amount: giftAmount,
message: message ? decodeURIComponent(message) : null,
transaction_id: payment._id
transaction_id: payment._id,
paymentMethod: usedWalletAmount > 0 ? 'mixed' : 'online'
})
await WalletTransactionModel.create({
user: receiverId,
bucket: 'gift',
type: 'credit',
amount: Math.round(giftAmount * (1 - GIFT_COMMISSION_RATE)),
referenceType: 'payment',
referenceId: gift._id,
description: 'هدیه دریافتی از کاربر دیگر (پس از کسر ۱۰٪ کارمزد)'
})
await creditGiftReceiver(receiverId, gift._id, giftAmount)
const receiverUser = await UserModel.findById(receiverId).select('user_name').lean()
const usernameParam = receiverUser?.user_name ? `&receiverUsername=${encodeURIComponent(receiverUser.user_name)}` : ''
if (isAndroid) {
const usernameParam = receiverUser?.user_name ? `&username=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(`modstagram://SuccessPay?type=gift&giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`)
}
const usernameParam = receiverUser?.user_name ? `&receiverUsername=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(`${process.env.APP_SITE}/gift/payment/success?giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`)
} catch (error) {
console.error('handleGiftPaymentCallback error:', error?.response?.data || error.message)
@@ -217,18 +265,15 @@ const replyToGift = async (req, res, next) => {
const initiateChatGift = async (req, res, next) => {
try {
const senderId = req.user._id
const { receiverId, amount, message, return_path: returnPath } = req.body
const { receiverId, amount, message, return_path: returnPath, platform, useWallet } = req.body
const { error, receiver, giftAmount } = await validateGiftTarget(senderId, receiverId, amount)
if (error) return res.status(error.status).json({ error: true, message: error.message })
const chargedBalance = await getBucketBalance(senderId, 'charged')
if (chargedBalance >= giftAmount) {
await WalletTransactionModel.create({
user: senderId,
bucket: 'charged',
type: 'debit',
amount: giftAmount,
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(senderId, giftAmount, useWallet)
if (fullyCovered) {
await debitWalletForPurchase(senderId, walletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
@@ -256,13 +301,14 @@ const initiateChatGift = async (req, res, next) => {
}
const safeReturnPath = typeof returnPath === 'string' && returnPath.startsWith('/') ? returnPath : '/'
const callbackUrl = `${callbackBase}/gifts/chat/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&message=${encodeURIComponent(message || '')}&returnPath=${encodeURIComponent(safeReturnPath)}`
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const callbackUrl = `${callbackBase}/gifts/chat/payment-callback?senderId=${senderId}&receiverId=${receiverId}&amount=${giftAmount}&walletAmount=${walletAmount}&message=${encodeURIComponent(message || '')}&returnPath=${encodeURIComponent(safeReturnPath)}${platformSuffix}`
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(giftAmount),
amount: toRial(gatewayAmount),
description: `هدیه در چت به ${receiver.first_name || receiver.user_name}`,
callback_url: callbackUrl
},
@@ -286,20 +332,26 @@ const initiateChatGift = async (req, res, next) => {
}
const handleChatGiftPaymentCallback = async (req, res) => {
const { Authority, Status, senderId, receiverId, amount, message, returnPath } = req.query
const { Authority, Status, senderId, receiverId, amount, walletAmount, message, returnPath, platform } = req.query
const isAndroid = platform === 'android'
const safeReturnPath = typeof returnPath === 'string' && returnPath.startsWith('/') ? decodeURIComponent(returnPath) : '/'
const failRedirect = `${process.env.APP_SITE}${safeReturnPath}?giftPaymentFailed=1`
const failRedirect = isAndroid
? `modstagram://FailedPay?type=gift${receiverId ? `&receiverId=${receiverId}` : ''}`
: `${process.env.APP_SITE}${safeReturnPath}?giftPaymentFailed=1`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const giftAmount = Number(amount)
const usedWalletAmount = Number(walletAmount) || 0
const gatewayAmount = giftAmount - usedWalletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(giftAmount)
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -309,8 +361,13 @@ const handleChatGiftPaymentCallback = async (req, res) => {
return res.redirect(failRedirect)
}
await debitWalletForPurchase(senderId, usedWalletAmount, {
referenceType: 'payment',
description: 'پرداخت هدیه از کیف پول'
})
const payment = await PaymentModel.create({
amount: giftAmount,
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: senderId,
@@ -325,12 +382,20 @@ const handleChatGiftPaymentCallback = async (req, res) => {
amount: giftAmount,
message: decodedMessage,
transaction_id: payment._id,
paymentMethod: 'online',
paymentMethod: usedWalletAmount > 0 ? 'mixed' : 'online',
viaChatMessage: true,
opened: false,
openedAt: null
})
if (isAndroid) {
const receiverUser = await UserModel.findById(receiverId).select('user_name').lean()
const usernameParam = receiverUser?.user_name ? `&username=${encodeURIComponent(receiverUser.user_name)}` : ''
return res.redirect(
`modstagram://SuccessPay?type=gift&giftId=${gift._id}&receiverId=${receiverId}${usernameParam}`
)
}
return res.redirect(
`${process.env.APP_SITE}${safeReturnPath}?giftId=${gift._id}&giftAmount=${giftAmount}&giftMessage=${encodeURIComponent(decodedMessage || '')}`
)

View File

@@ -14,6 +14,7 @@ const {
consumeFreeCreation,
freeCreationLimitResponse
} = require('../../../utils/freeCreationLimit')
const { canUseFreeBillboard, consumeFreeBillboard } = require('../../../utils/billboardFreeLimit')
const {
PROJECT_ACCEPT_RATE,
PROJECT_COMPLETION_COMMISSION_RATE,
@@ -646,24 +647,20 @@ const initiateAdvertisingPayment = async (req, res) => {
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (user.daily_free_request > 0 && canUseFreeCreation(user)) {
const consumed = await consumeFreeCreation(user)
if (!consumed.ok) {
return res.status(403).json(freeCreationLimitResponse())
}
user.daily_free_request -= 1
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const project = await ProjectModel.findById(advertisingId)
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Project not found')
res.status(404).send('Advertising not found')
return
}
project.payment_status = 'done' // Set project status to 'done'
project.status = await resolveApprovalStatus(userId, 'temp_accept') // Set project status to 'done'
await project.save()
res.status(200).json({ message: 'پروژه رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'شما قبلاً از درخواست رایگان امروز استفاده کرده‌اید.' })
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
}
} else {
const response = await axios.post(
@@ -802,24 +799,20 @@ const republishAdvertisingPayment = async (req, res) => {
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (user.daily_free_request > 0 && canUseFreeCreation(user)) {
const consumed = await consumeFreeCreation(user)
if (!consumed.ok) {
return res.status(403).json(freeCreationLimitResponse())
}
user.daily_free_request -= 1
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const project = await ProjectModel.findById(advertisingId)
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Project not found')
res.status(404).send('Advertising not found')
return
}
project.payment_status = 'done' // Set project status to 'done'
project.status = await resolveApprovalStatus(userId, 'temp_accept') // Set project status to 'done'
await project.save()
res.status(200).json({ message: 'پروژه رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'شما قبلاً از درخواست رایگان امروز استفاده کرده‌اید.' })
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
}
} else {
const response = await axios.post(

View File

@@ -9,14 +9,16 @@ 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 OfferTypeModel = require('../../../models/OfferTypeModel')
const OfferModel = require('../../../models/OfferModel')
const { default: axios } = require('axios')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const { PROJECT_ACCEPT_RATE, PROJECT_COMPLETION_COMMISSION_RATE, creditProjectShare } = require('../../../utils/projectCommission')
const { isVerifiedBadge } = require('../../../utils/verifyBadge')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const OFFER_COMMISSION_RATE = 0.3
const OFFER_COMMISSION_RATE = 0.5
const apiKey =
process.env.ZARINPAL_MERCHANT_ID ||
@@ -285,6 +287,7 @@ const paymentRequestAcceptWeb = async (req, res) => {
}
const requestId = req.body.requestId
const platform = req.body.platform
const request = await RequestModel.findById(requestId)
if (!request) {
return res.status(404).json({ error: 'Request not found' })
@@ -300,13 +303,14 @@ const paymentRequestAcceptWeb = async (req, res) => {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: apiKey,
amount: toRial(acceptAmount),
description: `پرداخت پذیرش همکاری برای پروژه ${project.title || projectId}`,
callback_url: `${callbackBase}/projects/payment-request-step-one-callback/web?projectId=${projectId}&requestId=${requestId}&userId=${userId}`
callback_url: `${callbackBase}/projects/payment-request-step-one-callback/web?projectId=${projectId}&requestId=${requestId}&userId=${userId}${platformSuffix}`
},
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
)
@@ -328,8 +332,14 @@ const paymentRequestAcceptWeb = async (req, res) => {
}
const paymentRequestAcceptWebCallback = async (req, res) => {
const { Authority, Status, projectId, requestId, userId } = req.query
const failRedirect = `${process.env.APP_SITE}/projects/payment/failed?projectId=${projectId || ''}`
const { Authority, Status, projectId, requestId, userId, platform } = req.query
const isAndroid = platform === 'android'
const failRedirect = isAndroid
? `modstagram://FailedPay?type=steps&projectId=${projectId || ''}`
: `${process.env.APP_SITE}/projects/payment/failed?projectId=${projectId || ''}`
const successRedirect = isAndroid
? `modstagram://SuccessPay?type=steps&projectId=${projectId || ''}&requestId=${requestId || ''}`
: `${process.env.APP_SITE}/projects/payment/success?projectId=${projectId}&type=accept`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
@@ -339,7 +349,7 @@ const paymentRequestAcceptWebCallback = async (req, res) => {
if (!request || !project) return res.redirect(failRedirect)
if (project.status === 'ongoing') {
return res.redirect(`${process.env.APP_SITE}/projects/payment/success?projectId=${projectId}&type=accept`)
return res.redirect(successRedirect)
}
const acceptAmount = Math.round(Number(request.price) * PROJECT_ACCEPT_RATE)
@@ -402,7 +412,11 @@ const paymentRequestAcceptWebCallback = async (req, res) => {
})
await notification.save()
return res.redirect(`${process.env.APP_SITE}/projects/payment/success?projectId=${projectId}&type=accept`)
return res.redirect(
isAndroid
? `modstagram://SuccessPay?type=steps&projectId=${projectId}&requestId=${requestId}&price=${acceptAmount}`
: successRedirect
)
} catch (error) {
console.error('paymentRequestAcceptWebCallback error:', error?.response?.data || error?.message || error)
return res.redirect(failRedirect)
@@ -557,8 +571,8 @@ const initiateAdvertisingPaymentWeb = async (req, res) => {
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (user.daily_free_request > 0) {
user.daily_free_request -= 1
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
@@ -570,7 +584,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: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
}
} else {
const response = await axios.post(
@@ -711,20 +725,20 @@ const republishAdvertisingPaymentWeb = async (req, res) => {
const user = await UserModel.findById(userId)
if (item.name === 'free' && item.price === 0) {
if (user.daily_free_request > 0) {
user.daily_free_request -= 1
if (canUseFreeBillboard(user)) {
await consumeFreeBillboard(user)
await user.save()
const project = await AdvertisingModel.findById(advertisingId)
if (!project) {
res.status(404).send('Project not found')
res.status(404).send('Advertising not found')
return
}
project.payment_status = 'done' // Set project status to 'done'
project.status = await resolveApprovalStatus(userId, 'temp_accept') // Set project status to 'done'
await project.save()
res.status(200).json({ message: 'پروژه رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
res.status(200).json({ message: 'بیلبورد رایگان با موفقیت ایجاد شد', advertisingId, type: 'free' })
} else {
res.status(403).json({ message: 'شما قبلاً از درخواست رایگان امروز استفاده کرده‌اید.' })
res.status(403).json({ message: 'سقف ۲۰۰ بیلبورد رایگان شما به پایان رسیده است.' })
}
} else {
const response = await axios.post(
@@ -853,14 +867,14 @@ const initiateOfferPaymentWeb = async (req, res) => {
const decoded = jwt.verify(token, process.env.APP_SECRET)
const userId = decoded.id
const { receiverId, offerType, platform } = req.body
const { receiverId, offerType, platform, useWallet } = req.body
if (!receiverId || !offerType) { return res.status(400).json({ error: 'Invalid data' }) }
const user = await UserModel.findById(userId)
const receiver = await UserModel.findById(receiverId)
if (!user || !receiver) { return res.status(404).json({ error: 'User not found' }) }
const price = await getOfferPriceFromDatabase(offerType) // ✅ ریال
const price = await getOfferPriceFromDatabase(offerType) // تومان
// 🎁 free offer
if (offerType === 'free' && user.monthly_free_offer > 0) {
@@ -894,9 +908,39 @@ const initiateOfferPaymentWeb = async (req, res) => {
})
}
const amount = Number(price) * 10 // ✅ ریال – بدون ×10
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(userId, price, useWallet)
// کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست
if (fullyCovered) {
const payment = await PaymentModel.create({
amount: price,
status: 'successful',
authority: null,
user_id: userId,
type: 'offer'
})
const receiverShare = Math.round(price * OFFER_COMMISSION_RATE)
const offer = await OfferModel.create({
sender: userId,
receiver: receiverId,
transaction_id: payment._id,
pendingCommissionAmount: receiverShare,
walletAmountUsed: walletAmount
})
await debitWalletForPurchase(userId, walletAmount, {
referenceType: 'offer',
referenceId: offer._id,
description: 'پرداخت آفر از کیف پول'
})
return res.json({ paidFromWallet: true, offerId: offer._id })
}
const amount = toRial(gatewayAmount)
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const callbackUrl = `https://api.modstagram.com/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}${platformSuffix}`
const callbackUrl = `https://api.modstagram.com/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&walletAmount=${walletAmount}${platformSuffix}`
const zarinpalRes = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
@@ -942,16 +986,21 @@ const handleOfferPaymentCallback = async (req, res) => {
const isAndroid = req.query.platform === 'android'
try {
const { Authority, Status, receiverId, offerType, userId } = req.query
const { Authority, Status, receiverId, offerType, userId, walletAmount } = req.query
const receiverUserForFail = receiverId ? await UserModel.findById(receiverId).select('user_name') : null
const failUsername = receiverUserForFail?.user_name || ''
const failParams = `receiverId=${receiverId || ''}&offerType=${offerType || ''}&username=${encodeURIComponent(failUsername)}`
if (Status !== 'OK') {
return isAndroid
? res.redirect('modstagram://FailedPay?type=offer')
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed`)
? res.redirect(`modstagram://FailedPay?type=offer&${failParams}`)
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed?userId=${receiverId || ''}&offerType=${offerType || ''}`)
}
const price = await getOfferPriceFromDatabase(offerType) // ریال
const amount = Number(price) * 10
const offerPriceToman = await getOfferPriceFromDatabase(offerType) // تومان
const usedWalletAmount = Number(walletAmount) || 0
const gatewayAmountToman = offerPriceToman - usedWalletAmount
const amount = toRial(gatewayAmountToman)
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
@@ -967,7 +1016,7 @@ const handleOfferPaymentCallback = async (req, res) => {
console.log('🔔payment:', data.code)
if (data.code === 100) {
const payment = await PaymentModel.create({
amount: (amount / 10),
amount: gatewayAmountToman,
status: 'successful',
authority: Authority,
ref_id: data.ref_id,
@@ -977,7 +1026,6 @@ const handleOfferPaymentCallback = async (req, res) => {
console.log('🔔payment:', payment)
const offerPriceToman = amount / 10
const receiverShare = Math.round(offerPriceToman * OFFER_COMMISSION_RATE)
// سهم گیرنده فقط با پاسخ‌گویی ظرف ۱۲ ساعت به اولین پیام دایرکت واریز می‌شود
@@ -986,7 +1034,16 @@ const handleOfferPaymentCallback = async (req, res) => {
sender: userId,
receiver: receiverId,
transaction_id: payment._id,
pendingCommissionAmount: receiverShare
pendingCommissionAmount: receiverShare,
walletAmountUsed: usedWalletAmount
})
// درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ کیف
// پول هم واقعاً کسر می‌شه (نگاه کن به utils/walletSplit.js)
await debitWalletForPurchase(userId, usedWalletAmount, {
referenceType: 'offer',
referenceId: offer._id,
description: 'پرداخت آفر از کیف پول'
})
// صفحه/دیپ‌لینکِ موفقیت برای دکمه‌ی «ارسال پیام» به شناسه (و نام‌کاربریِ) گیرنده
@@ -1005,13 +1062,14 @@ const handleOfferPaymentCallback = async (req, res) => {
}
return isAndroid
? res.redirect('modstagram://FailedPay?type=offer')
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed`)
? res.redirect(`modstagram://FailedPay?type=offer&${failParams}`)
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed?userId=${receiverId || ''}&offerType=${offerType || ''}`)
} catch (err) {
console.error(err)
const { receiverId, offerType } = req.query
return isAndroid
? res.redirect('modstagram://FailedPay?type=offer')
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed`)
? res.redirect(`modstagram://FailedPay?type=offer&receiverId=${receiverId || ''}&offerType=${offerType || ''}`)
: res.redirect(`${process.env.APP_SITE}/offer/payment/failed?userId=${receiverId || ''}&offerType=${offerType || ''}`)
}
}

View File

@@ -3,6 +3,7 @@ const OrderModel = require('../../../models/OrderModel')
const PaymentModel = require('../../../models/PaymentModel')
const ShopModel = require('../../../models/ShopModel')
const { notifyUser } = require('../../../utils/notifyUser')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
@@ -13,7 +14,7 @@ function toRial (tomanAmount) {
const initiateOrderPayment = async (req, res) => {
try {
const { orderId } = req.body
const { orderId, useWallet } = req.body
if (!orderId) {
return res.status(422).json({ error: true, message: 'سفارش مشخص نشده است' })
}
@@ -29,6 +30,42 @@ const initiateOrderPayment = async (req, res) => {
return res.status(400).json({ error: true, message: 'این سفارش قابل پرداخت نیست' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
req.user._id,
order.total_amount,
useWallet
)
// کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست
if (fullyCovered) {
await debitWalletForPurchase(req.user._id, walletAmount, {
referenceType: 'order',
referenceId: order._id,
description: `پرداخت سفارش شماره ${order._id} از کیف پول`
})
order.walletAmountUsed = walletAmount
order.status = 'processing'
await order.save()
await PaymentModel.create({
amount: order.total_amount,
status: 'successful',
authority: null,
user_id: order.buyer,
order_id: order._id,
type: 'shop_order'
})
const shop = await ShopModel.findById(order.shop)
if (shop) {
await notifyUser(req.app.get('io'), shop.owner, {
project_post_id: order._id,
type: 'shop_order',
title: 'سفارش جدید',
description: `یک سفارش جدید برای فروشگاه ${shop.name} ثبت و پرداخت شد.`
})
}
return res.json({ paidFromWallet: true })
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
@@ -38,7 +75,7 @@ const initiateOrderPayment = async (req, res) => {
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(order.total_amount),
amount: toRial(gatewayAmount),
description: `پرداخت سفارش شماره ${order._id}`,
callback_url: `${callbackBase}/payment/shop-order/callback?orderId=${order._id}`
},
@@ -48,6 +85,9 @@ const initiateOrderPayment = async (req, res) => {
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
order.paymentAuthority = result.authority
// فقط «قصدِ» استفاده از این مقدار کیف پول رو یادداشت می‌کنه — کسرِ واقعی توی
// کال‌بکِ موفقیت انجام می‌شه (نگاه کن به بالای فایل: utils/walletSplit.js)
order.walletAmountUsed = walletAmount
await order.save()
return res.json({ authority: result.authority })
}
@@ -77,8 +117,15 @@ const handleOrderPaymentCallback = async (req, res) => {
return res.redirect(failRedirect)
}
// این مقدار توی initiateOrderPayment فقط «یادداشت» شده بود (نگاه کن به utils/
// walletSplit.js) — کیف پول تا همین لحظه دست‌نخورده مونده، پس اگه پرداختِ درگاه
// ناموفق/رهاشده باشه، چیزی برای برگردوندن وجود نداره
const walletAmount = order.walletAmountUsed || 0
const gatewayAmount = order.total_amount - walletAmount
if (Status !== 'OK') {
order.status = 'failed'
order.walletAmountUsed = 0
await order.save()
await PaymentModel.create({
amount: order.total_amount,
@@ -102,15 +149,23 @@ const handleOrderPaymentCallback = async (req, res) => {
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(order.total_amount)
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
// درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ
// کیف پول هم واقعاً کسر می‌شه
await debitWalletForPurchase(order.buyer, walletAmount, {
referenceType: 'order',
referenceId: order._id,
description: `پرداخت سفارش شماره ${order._id} از کیف پول`
})
await PaymentModel.create({
amount: order.total_amount,
amount: gatewayAmount,
status: 'successful',
authority: Authority,
user_id: order.buyer,
@@ -135,6 +190,7 @@ const handleOrderPaymentCallback = async (req, res) => {
}
order.status = 'failed'
order.walletAmountUsed = 0
await order.save()
await PaymentModel.create({
amount: order.total_amount,

View File

@@ -72,9 +72,25 @@ const registerUser = async (req, res, next) => {
const otp = generateOTP()
const otpSentAt = new Date()
const setFields = { mobile, otp: String(otp), otpSentAt }
// سیستم معرفی به دوستان: کدِ معرفی فقط همین یک‌بار (اولین باری که این شماره اصلاً
// ثبت‌نام می‌شه) ثبت می‌شه — اگه existingUser از قبل referred_by داشته باشه (مثلاً
// کاربر OTP رو دوباره درخواست کرده)، دست‌نخورده می‌مونه؛ پاداشِ واقعی فقط با
// تکمیلِ ثبت‌نام (setUserType) پرداخت می‌شه، نه همین‌جا
const referralCode = typeof req.body.referral_code === 'string'
? req.body.referral_code.trim()
: null
if (referralCode && !existingUser?.referred_by) {
const referrer = await UserModel.findOne({ referral_code: referralCode }).select('_id')
if (referrer && String(referrer._id) !== String(existingUser?._id)) {
setFields.referred_by = referrer._id
}
}
await UserModel.findOneAndUpdate(
{ mobile },
{ $set: { mobile, otp: String(otp), otpSentAt } },
{ $set: setFields },
{ upsert: true, new: true, setDefaultsOnInsert: true }
)

View File

@@ -1,7 +1,10 @@
/* eslint-disable camelcase */
const UserModel = require('../../../models/UserModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const jwt = require('jsonwebtoken')
const REFERRAL_REWARD_AMOUNT = 10000
const setUserType = async (req, res, next) => {
try {
const { user_type } = req.body
@@ -29,6 +32,25 @@ const setUserType = async (req, res, next) => {
// همه کاربران با نوع «user» ثبت می‌شوند
user.user_type = normalizedType
// سیستم معرفی به دوستان: اینجا آخرین قدمِ ثبت‌نامه (طبقِ authSteps.js —
// getRegistrationStep فقط وقتی user_type ست بشه «complete» می‌شه)، پس دقیقاً
// همین لحظه‌ست که «این کاربر واقعاً ثبت‌نام کرد» معنی پیدا می‌کنه، نه صرفِ وارد
// کردنِ شماره موبایل (که ممکنه هیچ‌وقت تکمیل نشه). referral_rewarded جلوی
// پرداختِ دوباره رو می‌گیره اگه این مسیر به هر دلیلی دوباره صدا زده بشه
if (user.referred_by && !user.referral_rewarded) {
await WalletTransactionModel.create({
user: user.referred_by,
bucket: 'gift',
type: 'credit',
amount: REFERRAL_REWARD_AMOUNT,
referenceType: 'payment',
referenceId: user._id,
description: 'پاداش معرفی کاربر جدید به مدستاگرام'
})
user.referral_rewarded = true
}
await user.save()
return res.json({

View File

@@ -278,7 +278,7 @@ async function getRankedExplorePosts ({
const userIds = [...new Set(posts.map((p) => String(p.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users'
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users cooperation_type cooperation_abroad lat lng'
)
.lean()
@@ -442,7 +442,7 @@ async function getPredictedTrendPosts ({
const userIds = [...new Set(scored.map((p) => String(p.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users'
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users cooperation_type cooperation_abroad lat lng'
)
.lean()
@@ -1313,7 +1313,7 @@ const getPostsWeb = async (req, res, next) => {
const userIds = [...new Set(posts.map((p) => String(p.user_id)))]
const users = await UserModel.find({ _id: { $in: userIds } })
.select(
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate lat lng blocked_users'
'_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate lat lng blocked_users cooperation_type cooperation_abroad'
)
.lean()
@@ -1338,7 +1338,7 @@ const getPostsWeb = async (req, res, next) => {
.lean()
const userIds = [...new Set(explorePosts.map((p) => String(p.user_id)))]
const usersMap = await UserModel.find({ _id: { $in: userIds } })
.select('_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users')
.select('_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate blocked_users cooperation_type cooperation_abroad lat lng')
.lean()
const usersById = Object.fromEntries(usersMap.map((u) => [String(u._id), u]))
@@ -1422,7 +1422,7 @@ const getPostsWeb = async (req, res, next) => {
}
}
const userSelectFields = '_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate pinned_post_ids'
const userSelectFields = '_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image is_verified verify_badge user_score rate pinned_post_ids cooperation_type cooperation_abroad lat lng'
let users = await UserModel.find(userFilter)
.select(userSelectFields)
.lean()

View File

@@ -0,0 +1,54 @@
const crypto = require('crypto')
const UserModel = require('../../../models/UserModel')
// بدون حروف/ارقامِ به‌هم‌شبیه (0/O، 1/I/L) — چون این کد قراره دستی هم کپی/تایپ بشه
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const CODE_LENGTH = 8
const MAX_ATTEMPTS = 5
function generateCode () {
const bytes = crypto.randomBytes(CODE_LENGTH)
let code = ''
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]
}
return code
}
/**
* کدِ معرفیِ کاربرِ لاگین‌شده رو برمی‌گردونه؛ اگه هنوز نداشته باشه (referral_code
* روی همه‌ی کاربرهای قدیمی null هست چون این فیلد قبلاً مرده بود) با retry-on-collision
* یکی می‌سازه. لینکِ نهاییِ اشتراک‌گذاری رو فرانت (وب/اندروید) با همین کد می‌سازه:
* https://modstagram.com/users/<username>?ref=<code>
*/
const getReferralCode = async (req, res, next) => {
try {
const userId = req.user._id
const user = await UserModel.findById(userId).select('referral_code')
if (!user) return res.status(404).json({ error: true, message: 'کاربر یافت نشد' })
if (user.referral_code) {
return res.json({ referral_code: user.referral_code })
}
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const code = generateCode()
try {
user.referral_code = code
await user.save()
return res.json({ referral_code: code })
} catch (error) {
// E11000 duplicate key — کد به‌طور اتفاقی قبلاً برای یه کاربر دیگه ساخته شده؛
// بی‌نهایت‌بعیده (32^8 حالت) ولی retry ساده و بی‌ضرره
if (error?.code === 11000) continue
throw error
}
}
return res.status(500).json({ error: true, message: 'ساخت کد معرفی ناموفق بود، دوباره تلاش کنید' })
} catch (error) {
next(error)
}
}
module.exports = { getReferralCode }

View File

@@ -193,7 +193,7 @@ const requestWithdrawal = async (req, res, next) => {
const initiateWalletCharge = async (req, res, next) => {
try {
const { amount } = req.body
const { amount, platform } = req.body
const chargeAmount = Number(amount)
if (!Number.isFinite(chargeAmount) || chargeAmount <= 0) {
return walletError(res, 422, 'مبلغ نامعتبر است')
@@ -204,13 +204,14 @@ const initiateWalletCharge = async (req, res, next) => {
return walletError(res, 500, 'آدرس بازگشت پرداخت تنظیم نشده است')
}
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(chargeAmount),
description: 'شارژ کیف پول مدستاگرام',
callback_url: `${callbackBase}/wallet/charge/callback?userId=${req.user._id}&amount=${chargeAmount}`
callback_url: `${callbackBase}/wallet/charge/callback?userId=${req.user._id}&amount=${chargeAmount}${platformSuffix}`
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -228,9 +229,14 @@ const initiateWalletCharge = async (req, res, next) => {
}
const handleWalletChargeCallback = async (req, res) => {
const { Authority, Status, userId, amount } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/wallet?charge=failed`
const successRedirect = `${process.env.APP_SITE}/settings/wallet?charge=success`
const { Authority, Status, userId, amount, platform } = req.query
const isAndroid = platform === 'android'
const failRedirect = isAndroid
? 'modstagram://FailedPay?type=wallet'
: `${process.env.APP_SITE}/settings/wallet?charge=failed`
const successRedirect = isAndroid
? 'modstagram://SuccessPay?type=wallet'
: `${process.env.APP_SITE}/settings/wallet?charge=success`
try {
if (Status !== 'OK') return res.redirect(failRedirect)