This commit is contained in:
payacom
2026-10-06 17:08:44 +03:30
parent fade8543e2
commit 367416d419
14 changed files with 6087 additions and 5937 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ const { resolveAdPrice } = require('../../../utils/adPricing')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard']
@@ -220,7 +221,7 @@ const createAd = async (req, res, next) => {
success: true,
adId: ad._id,
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}

View File

@@ -3,6 +3,7 @@ const BookingModel = require('../../../models/BookingModel')
const PaymentModel = require('../../../models/PaymentModel')
const { notifyUser } = require('../../../utils/notifyUser')
const { BOOKING_COMMISSION_RATE, creditBookingShare } = require('../../../utils/bookingCommission')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const apiKey =
process.env.ZARINPAL_MERCHANT_ID ||
@@ -59,7 +60,7 @@ const initiateBookingPayment = async (req, res) => {
await booking.save()
return res.status(200).json({
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}

View File

@@ -6,6 +6,7 @@ const UserModel = require('../../../models/UserModel')
const PaymentModel = require('../../../models/PaymentModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const GIFT_MIN_AMOUNT = 2000
@@ -123,7 +124,7 @@ const initiateGiftPayment = async (req, res, next) => {
if (result?.code === 100 && result?.authority) {
return res.json({
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
@@ -320,7 +321,7 @@ const initiateChatGift = async (req, res, next) => {
return res.json({
paidFromWallet: false,
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}

View File

@@ -53,8 +53,14 @@ const getOfferTypes = async (req, res, next) => {
]
}
const canFree =
(user.monthly_free_offer || 0) > 0 && canUseFreeCreation(user)
// رایگان بودن درخواست به «گیرنده» بستگی دارد: اگر گیرنده «دریافت هدیه» را غیرفعال
// کرده باشه، فرستنده (با سهمیه‌ی رایگانِ خودش) می‌تونه درخواست رایگان بده
const receiverForFree = req.query.userId
? await UserModel.findById(req.query.userId).select('gift_enabled offer_payment_enabled').lean()
: null
// رایگان وقتی فعاله که گیرنده «دریافت هدیه» یا «دریافت پرداختی از چت» رو غیرفعال کرده باشه
const freeEligible = receiverForFree?.gift_enabled === false || receiverForFree?.offer_payment_enabled === false
const canFree = freeEligible && canUseFreeCreation(user)
// رایگان را حذف نکن — اگر مجاز نیست، با فلگ disabled برگردان تا UI نشان دهد
offerTypes = offerTypes.map((t) => {
@@ -63,8 +69,8 @@ const getOfferTypes = async (req, res, next) => {
plain.disabled = !canFree
plain.unavailableReason = canFree
? null
: (user.monthly_free_offer || 0) <= 0
? 'سهمیه رایگان ماهانه شما تمام شده است'
: !freeEligible
? 'برای درخواست رایگان، دریافت هدیه از چت را در تنظیمات حساب غیرفعال کنید'
: 'سقف ثبت رایگان شما به پایان رسیده است'
}
return plain
@@ -206,19 +212,15 @@ const handleSuccessfulOfferPayment = async (req, res) => {
// اگر گیرنده «دریافت پرداختی از چت» رو غیرفعال کرده باشه، درخواست همکاری براش
// همیشه رایگانه — مستقل از سهمیه‌ی رایگان ماهانه‌ی فرستنده
const receiverOffersFree = reciverUser?.offer_payment_enabled === false
const receiverFreeEligible = reciverUser?.gift_enabled === false || reciverUser?.offer_payment_enabled === false
let price
if (receiverOffersFree) {
price = 0
} else if (user.monthly_free_offer > 0 && offerType === 'free' && canUseFreeCreation(user)) {
if (offerType === 'free' && receiverFreeEligible && canUseFreeCreation(user)) {
const consumed = await consumeFreeCreation(user)
if (!consumed.ok) {
return res.status(403).json(freeCreationLimitResponse())
}
price = 0
user.monthly_free_offer -= 1
await user.save()
} else if (offerType === 'free') {
return res.status(403).json({
error: true,

View File

@@ -21,9 +21,15 @@ const {
creditProjectShare
} = require('../../../utils/projectCommission')
const { isVerifiedBadge } = require('../../../utils/verifyBadge')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const apiKey = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE // Replace with your ZarinPal API Key
// زرین‌پال callback رو فقط وقتی قبول می‌کنه که دامنه‌اش همون دامنه‌ی ثبت‌شده‌ی مرچنت باشه
// (APP_SITE_CallBack = دامنه‌ی اصلی)؛ APP_URL (زیردامنه‌ی app) باعث خطای «دامنه مجاز نیست» می‌شه
const getCallbackBase = () =>
(process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '')
// درگاه قدیمیِ REST زرین‌پال (/pg/rest/WebGate) منسوخ شده — مبلغ به API نسخه‌ی v4
// باید به ریال ارسال بشه، نه تومان (برخلاف API قدیمی)
function toRial (tomanAmount) {
@@ -108,7 +114,7 @@ const initiatePayment = async (req, res) => {
merchant_id: apiKey,
amount: toRial(price),
description: `پرداخت برای سفارش شماره ${projectId}`,
callback_url: `${process.env.APP_URL}/projects/payment?projectId=${projectId}&itemName=${itemName}&userId=${userId}` // Assuming your backend URL for handling payment callback
callback_url: `${getCallbackBase()}/projects/payment?projectId=${projectId}&itemName=${itemName}&userId=${userId}` // Assuming your backend URL for handling payment callback
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -116,7 +122,7 @@ const initiatePayment = async (req, res) => {
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)
@@ -246,7 +252,7 @@ const paymentRequestStepOne = async (req, res) => {
merchant_id: apiKey,
amount: toRial(acceptAmount),
description: `پرداخت برای سفارش شماره ${projectId}`,
callback_url: `${process.env.APP_URL}/projects/payment-request-step-one-callback?projectId=${projectId}&requestId=${requestId}&userId=${userId}` // Callback URL including requestId
callback_url: `${getCallbackBase()}/projects/payment-request-step-one-callback?projectId=${projectId}&requestId=${requestId}&userId=${userId}` // Callback URL including requestId
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -254,7 +260,7 @@ const paymentRequestStepOne = async (req, res) => {
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)
@@ -290,7 +296,7 @@ const paymentRequestStepTwo = async (req, res) => {
merchant_id: apiKey,
amount: toRial(secondInstallmentAmount),
description: `پرداخت برای سفارش شماره ${projectId}`,
callback_url: `${process.env.APP_URL}/projects/payment-request-step-two-callback?projectId=${projectId}&userId=${userId}&installmentNumber=${installmentNumber}` // Callback URL
callback_url: `${getCallbackBase()}/projects/payment-request-step-two-callback?projectId=${projectId}&userId=${userId}&installmentNumber=${installmentNumber}` // Callback URL
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -298,7 +304,7 @@ const paymentRequestStepTwo = async (req, res) => {
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)
@@ -335,7 +341,7 @@ const paymentRequestStepAll = async (req, res) => {
merchant_id: apiKey,
amount: toRial(secondInstallmentAmount),
description: `پرداخت برای سفارش شماره ${projectId}`,
callback_url: `${process.env.APP_URL}/projects/payment-request-step-all-callback?projectId=${projectId}&userId=${userId}` // Callback URL
callback_url: `${getCallbackBase()}/projects/payment-request-step-all-callback?projectId=${projectId}&userId=${userId}` // Callback URL
},
{ headers: { 'Content-Type': 'application/json' } }
)
@@ -343,7 +349,7 @@ const paymentRequestStepAll = async (req, res) => {
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)
@@ -690,14 +696,14 @@ const initiateAdvertisingPayment = async (req, res) => {
merchant_id: apiKey,
amount: toRial(showDiscount === true || showDiscount === 'true' ? Number(price + 20000) : Number(price)),
description: `پرداخت برای سفارش شماره ${advertisingId}`,
callback_url: `${process.env.APP_URL}/advertising/payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback
callback_url: `${getCallbackBase()}/advertising/payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)
@@ -840,14 +846,14 @@ const republishAdvertisingPayment = async (req, res) => {
merchant_id: apiKey,
amount: toRial(showDiscount === true || showDiscount === 'true' ? Number(((price + 20000) * 70) / 100) : Number(((price) * 70) / 100)),
description: `پرداخت برای سفارش شماره ${advertisingId}`,
callback_url: `${process.env.APP_URL}/advertising/republish-verify-payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback
callback_url: `${getCallbackBase()}/advertising/republish-verify-payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
// Payment request was successful
res.status(200).json({ authority: result.authority })
res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) })
} else {
// Payment request failed
console.error('Error occurred while initiating payment:', response.data)

View File

@@ -18,6 +18,8 @@ const { PROJECT_ACCEPT_RATE, PROJECT_COMPLETION_COMMISSION_RATE, creditProjectSh
const { isVerifiedBadge } = require('../../../utils/verifyBadge')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { needsUsernameAndPassword } = require('../../../utils/authSteps')
const { canUseFreeCreation, consumeFreeCreation } = require('../../../utils/freeCreationLimit')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const OFFER_COMMISSION_RATE = 0.5
@@ -331,7 +333,7 @@ const paymentRequestAcceptWeb = async (req, res) => {
if (result?.code === 100 && result?.authority) {
return res.status(200).json({
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
@@ -487,7 +489,7 @@ const initiateProjectCompletionPaymentWeb = async (req, res) => {
if (result?.code === 100 && result?.authority) {
return res.status(200).json({
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
@@ -914,38 +916,30 @@ const initiateOfferPaymentWeb = async (req, res) => {
})
}
// 🎁 free offer
if (offerType === 'free' && user.monthly_free_offer > 0) {
// 🎁 رایگان: فقط وقتی مجازه که گیرنده «دریافت هدیه» یا «دریافت پرداختی از چت» رو
// غیرفعال کرده باشه، و سهمیه‌ی رایگان ماهانه‌ی فرستنده هنوز باقی باشه.
// درخواستِ پولی (offerType غیر از free) همیشه با قیمت واقعی ثبت می‌شه
if (offerType === 'free') {
const receiverFreeEligible = receiver.gift_enabled === false || receiver.offer_payment_enabled === false
if (!receiverFreeEligible) {
return res.status(403).json({
error: true,
message: 'برای درخواست رایگان، گیرنده باید دریافت هدیه یا دریافت پرداختی از چت را غیرفعال کرده باشد'
})
}
if (!canUseFreeCreation(user)) {
return res.status(403).json({ error: true, message: 'سهمیه درخواست رایگان ماهانه شما تمام شده است.' })
}
const consumed = await consumeFreeCreation(user)
if (!consumed.ok) {
return res.status(403).json({ error: true, message: 'سهمیه درخواست رایگان ماهانه شما تمام شده است.' })
}
const offer = await OfferModel.create({
sender: userId,
receiver: receiverId,
transaction_id: null
})
// updateOne + $inc به‌جای user.save() — save() کل سند رو ری‌ولیدیت می‌کنه و برای
// کاربرهایی که مقدار قدیمی/نامعتبر توی فیلدهای دیگه (مثلاً income_range) دارن
// با ValidationError شکست می‌خوره، حتی وقتی فقط monthly_free_offer تغییر کرده
await UserModel.updateOne({ _id: userId }, { $inc: { monthly_free_offer: -1 } })
return res.json({
type: 'free',
offerId: offer._id
})
}
// 🎁 گیرنده «دریافت پرداختی از چت» رو غیرفعال کرده — درخواست همکاری براش همیشه
// رایگانه، مستقل از سهمیه‌ی رایگان ماهانه‌ی فرستنده
if (receiver.offer_payment_enabled === false) {
const offer = await OfferModel.create({
sender: userId,
receiver: receiverId,
transaction_id: null
})
return res.json({
type: 'free',
offerId: offer._id
})
return res.json({ type: 'free', offerId: offer._id })
}
// نوعِ «رایگان» قیمتی توی OfferTypeModel نداره (اصلاً پرایس‌دار نیست، سهمیه‌محوره) —
@@ -975,7 +969,7 @@ const initiateOfferPaymentWeb = async (req, res) => {
})
await debitWalletForPurchase(userId, walletAmount, {
referenceType: 'offer',
referenceType: 'payment',
referenceId: offer._id,
description: 'پرداخت آفر از کیف پول'
})
@@ -985,7 +979,7 @@ const initiateOfferPaymentWeb = async (req, res) => {
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}&walletAmount=${walletAmount}${platformSuffix}`
const callbackUrl = `${(process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '')}/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',
@@ -1010,7 +1004,7 @@ const initiateOfferPaymentWeb = async (req, res) => {
if (result.code === 100) {
return res.json({
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
@@ -1086,7 +1080,7 @@ const handleOfferPaymentCallback = async (req, res) => {
// درگاه فقط بخشِ باقی‌مونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ کیف
// پول هم واقعاً کسر می‌شه (نگاه کن به utils/walletSplit.js)
await debitWalletForPurchase(userId, usedWalletAmount, {
referenceType: 'offer',
referenceType: 'payment',
referenceId: offer._id,
description: 'پرداخت آفر از کیف پول'
})

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ const OrderModel = require('../../../models/OrderModel')
const UserModel = require('../../../models/UserModel')
const WalletTransactionModel = require('../../../models/WalletTransactionModel')
const WalletWithdrawalRequestModel = require('../../../models/WalletWithdrawalRequestModel')
const PaymentModel = require('../../../models/PaymentModel')
const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage')
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const GIFT_WITHDRAWAL_MIN = 1000000
@@ -199,49 +201,89 @@ const initiateWalletCharge = async (req, res, next) => {
return walletError(res, 422, 'مبلغ نامعتبر است')
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
const callbackBase = (process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '')
if (!callbackBase) {
return walletError(res, 500, 'آدرس بازگشت پرداخت تنظیم نشده است')
}
const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : ''
// کال‌بک باید یک آدرس ثابت و بدون query string باشد تا با دامنه/Callback
// ثبت‌شده در زرین‌پال دقیقاً یکی باشد. اطلاعات پرداخت را در دیتابیس نگه می‌داریم.
const callbackUrl = `${callbackBase}/wallet/charge/callback`
const paymentType = platform === 'android' ? 'wallet_charge_android' : 'wallet_charge_web'
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}${platformSuffix}`
callback_url: callbackUrl
},
{ headers: { 'Content-Type': 'application/json' } }
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
return res.json({ authority: result.authority })
await PaymentModel.create({
amount: chargeAmount,
status: 'pending',
authority: result.authority,
user_id: req.user._id,
type: paymentType
})
return res.json({
authority: result.authority,
paymentUrl: `${buildPaymentPageUrl(result.authority)}`
})
}
console.error('ZarinPal wallet charge initiate error:', response.data)
return walletError(res, 502, 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.')
} catch (error) {
console.error('initiateWalletCharge error:', error?.response?.data || error?.message || error)
next(error)
}
}
const handleWalletChargeCallback = async (req, res) => {
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`
const { Authority, Status } = req.query
let payment = null
let failRedirect = `${process.env.APP_SITE}/settings/wallet/payment/failed`
let successRedirect = `${process.env.APP_SITE}/settings/wallet/payment/success`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
if (!Authority) return res.redirect(failRedirect)
payment = await PaymentModel.findOne({
authority: Authority,
type: { $in: ['wallet_charge_android', 'wallet_charge_web'] }
})
if (!payment) return res.redirect(failRedirect)
const chargeAmount = Number(payment.amount)
const isAndroid = payment.type === 'wallet_charge_android'
failRedirect = isAndroid
? `modstagram://FailedPay?type=wallet&amount=${encodeURIComponent(chargeAmount)}`
: `${process.env.APP_SITE}/settings/wallet/payment/failed?amount=${encodeURIComponent(chargeAmount)}`
successRedirect = isAndroid
? `modstagram://SuccessPay?type=wallet&amount=${encodeURIComponent(chargeAmount)}`
: `${process.env.APP_SITE}/settings/wallet/payment/success?amount=${encodeURIComponent(chargeAmount)}`
if (Status !== 'OK') {
payment.status = 'failed'
await payment.save()
return res.redirect(failRedirect)
}
// اگر callback دوباره ارسال شد، شارژ دوباره به کیف پول اضافه نشود.
if (payment.status === 'successful') {
return res.redirect(successRedirect)
}
const chargeAmount = Number(amount)
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
@@ -249,41 +291,60 @@ const handleWalletChargeCallback = async (req, res) => {
authority: Authority,
amount: toRial(chargeAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
const user = await UserModel.findById(userId)
if (!user) return res.redirect(failRedirect)
const user = await UserModel.findById(payment.user_id)
if (!user) {
payment.status = 'failed'
await payment.save()
return res.redirect(failRedirect)
}
await WalletTransactionModel.create({
user: userId,
user: payment.user_id,
bucket: 'charged',
type: 'credit',
amount: chargeAmount,
referenceType: 'payment',
referenceId: payment._id,
description: 'شارژ کیف پول'
})
const bonus = Math.round(chargeAmount * CHARGE_BONUS_RATE)
if (bonus > 0) {
await WalletTransactionModel.create({
user: userId,
user: payment.user_id,
bucket: 'gift',
type: 'credit',
amount: bonus,
referenceType: 'payment',
referenceId: payment._id,
description: 'پاداش شارژ کیف پول (۵٪)'
})
}
payment.status = 'successful'
await payment.save()
return res.redirect(successRedirect)
}
payment.status = 'failed'
await payment.save()
return res.redirect(failRedirect)
} catch (error) {
console.error('handleWalletChargeCallback error:', error?.response?.data || error.message)
console.error('handleWalletChargeCallback error:', error?.response?.data || error?.message || error)
if (payment) {
try {
payment.status = 'failed'
await payment.save()
} catch (saveError) {
console.error('Failed to update wallet payment status:', saveError?.message || saveError)
}
}
return res.redirect(failRedirect)
}
}

View File

@@ -13,6 +13,7 @@ try {
}
const http = require('http').createServer(app);
const multer = require('multer');
const { buildZarinpalStartPayUrl } = require('./utils/zarinpalPaymentPage');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const moment = require('moment-jalaali');
@@ -425,6 +426,17 @@ app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => {
// فایل/ویس توی چت (وب و اندروید، چون هر دو همین یه اندپوینتِ مشترک رو صدا می‌زنن) با
// «Server error» شکست می‌خورد. همون الگوی multer.diskStorage که posts استفاده می‌کنه،
// اینجا هم (مستقیماً روی پوشه‌ی نهایی، بدونِ نیاز به fs.move جداگانه) وصل شد
app.get('/api/v1/pay/:authority', (req, res) => {
const authority = String(req.params.authority || '')
if (!/^[A-Za-z0-9]{1,64}$/.test(authority)) {
return res.status(400).send('invalid authority')
}
const target = buildZarinpalStartPayUrl(authority)
res.set('Content-Type', 'text/html; charset=utf-8')
res.set('Referrer-Policy', 'origin')
res.send(`<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="referrer" content="origin"><meta name="viewport" content="width=device-width,initial-scale=1"><title>انتقال به درگاه پرداخت</title></head><body><p>در حال انتقال به درگاه پرداخت…</p><a href="${target}">ادامه</a><script>location.replace(${JSON.stringify(target)});</script></body></html>`)
})
const chatFileUploadDir = path.join(__dirname, '../storage/messages')
if (!fs.existsSync(chatFileUploadDir)) {
fs.mkdirSync(chatFileUploadDir, { recursive: true })

View File

@@ -758,7 +758,7 @@ const userSchema = new mongoose.Schema({
}],
monthly_free_offer: {
type: Number,
default: 1
default: 0
},
last_free_offer_date: {
type: Date,

View File

@@ -1,3 +1,4 @@
const UserModel = require('../models/UserModel')
const FREE_LIFETIME_CREATION_LIMIT = 100
const canUseFreeCreation = (user) => {
@@ -18,8 +19,20 @@ const consumeFreeCreation = async (user) => {
}
}
// updateOne + $inc (اتمیک، فقط همین فیلد) به‌جای user.save() — save() کل سند رو
// ری‌ولیدیت می‌کنه و برای کاربرهایی با مقدار نامعتبر در فیلدهای دیگه (مثلاً
// income_range) با ValidationError شکست می‌خوره
const res = await UserModel.updateOne(
{ _id: user._id, lifetime_free_creations: { $lt: FREE_LIFETIME_CREATION_LIMIT } },
{ $inc: { lifetime_free_creations: 1 } }
)
if (!res.modifiedCount) {
return {
ok: false,
message: `سقف ${FREE_LIFETIME_CREATION_LIMIT} ثبت رایگان (پروژه، بیلبورد و درخواست) به پایان رسیده است.`,
}
}
user.lifetime_free_creations = (user.lifetime_free_creations || 0) + 1
await user.save()
return { ok: true }
}

View File

@@ -57,3 +57,42 @@ async function optimizeImageFile(filePath, { maxDimension = DEFAULT_MAX_DIMENSIO
}
module.exports = { optimizeImageFile, DEFAULT_MAX_DIMENSION, DEFAULT_JPEG_QUALITY }
const VARIANT_WIDTHS = [480, 800]
/**
* نسخه‌های کوچک‌تر (WebP، با حفظ نسبت تصویر) کنار فایل اصلی می‌سازد و یک placeholder
* خیلی کم‌حجم هم می‌سازد. فایل اصلی دست‌نخورده می‌ماند و نسخه‌ای که از اصل بزرگ‌تر
* نباشد ساخته نمی‌شود.
*/
async function generateImageVariants(filePath, { widths = VARIANT_WIDTHS } = {}) {
if (!filePath) return
try {
if (!(await fs.pathExists(filePath))) return
const metadata = await sharp(filePath, { failOn: 'none' }).metadata()
if (!metadata.width || !metadata.height) return
const dir = path.dirname(filePath)
const base = path.basename(filePath, path.extname(filePath))
const source = sharp(filePath, { failOn: 'none' }).rotate()
for (const width of widths) {
if (width >= metadata.width) continue
await source
.clone()
.resize({ width, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(path.join(dir, `${base}.w${width}.webp`))
}
await source
.clone()
.resize({ width: 24, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 40 })
.toFile(path.join(dir, `${base}.lqip.webp`))
} catch (error) {
console.error('[imageOptimize] variants failed:', error.message)
}
}
module.exports.generateImageVariants = generateImageVariants

View File

@@ -0,0 +1,18 @@
const ZARINPAL_START_PAY = 'https://www.zarinpal.com/pg/StartPay'
function getPaymentPageBase () {
return (process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '')
}
// زرین‌پال صفحه‌ی پرداخت رو فقط وقتی باز می‌کنه که Referer درخواست یه دامنه‌ی مجاز باشه؛
// لینکِ مستقیمِ StartPay از مرورگرِ خارجی (مثلاً اندروید) Referer نداره و رد می‌شه. این
// آدرس روی دامنه‌ی خودمون یه صفحه‌ی redirect می‌زنه تا Referer درست ارسال بشه
function buildPaymentPageUrl (authority) {
return `${getPaymentPageBase()}/pay/${encodeURIComponent(String(authority))}`
}
function buildZarinpalStartPayUrl (authority) {
return `${ZARINPAL_START_PAY}/${encodeURIComponent(String(authority))}`
}
module.exports = { buildPaymentPageUrl, buildZarinpalStartPayUrl }