pay
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
12
index.js
12
index.js
@@ -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 })
|
||||
|
||||
@@ -758,7 +758,7 @@ const userSchema = new mongoose.Schema({
|
||||
}],
|
||||
monthly_free_offer: {
|
||||
type: Number,
|
||||
default: 1
|
||||
default: 0
|
||||
},
|
||||
last_free_offer_date: {
|
||||
type: Date,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
18
utils/zarinpalPaymentPage.js
Normal file
18
utils/zarinpalPaymentPage.js
Normal 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 }
|
||||
Reference in New Issue
Block a user