This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -0,0 +1,194 @@
const BookingConfigModel = require('../../../models/BookingConfigModel')
const UserModel = require('../../../models/UserModel')
const WEEK_DAYS = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri']
function bookingError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function findOwnedConfig (configId, userId) {
const config = await BookingConfigModel.findById(configId)
if (!config) return { error: 'NOT_FOUND' }
if (String(config.owner) !== String(userId)) return { error: 'FORBIDDEN' }
return { config }
}
const createDraftBookingConfig = async (req, res, next) => {
try {
const { name } = req.body
if (!name) {
return bookingError(res, 422, 'نام رزرو الزامی است')
}
const config = await BookingConfigModel.create({
owner: req.user._id,
name,
status: 'draft'
})
return res.json({ message: 'رزرو ایجاد شد', configId: config._id, config })
} catch (error) {
next(error)
}
}
const getMyBookingConfigs = async (req, res, next) => {
try {
const configs = await BookingConfigModel.find({ owner: req.user._id }).sort({ createdAt: -1 })
return res.json({ configs })
} catch (error) {
next(error)
}
}
const getBookingConfigById = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
return res.json({ config })
} catch (error) {
next(error)
}
}
const updateBookingConfigServices = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { serviceIds } = req.body
if (!Array.isArray(serviceIds) || serviceIds.length === 0) {
return bookingError(res, 422, 'حداقل یک خدمت انتخاب کنید')
}
const user = await UserModel.findById(req.user._id).select('services')
const selected = (user.services || []).filter((service) =>
serviceIds.includes(String(service._id))
)
if (selected.length === 0) {
return bookingError(res, 422, 'خدمات انتخاب‌شده یافت نشد')
}
config.services = selected.map((service) => ({
serviceId: String(service._id),
title: service.title,
price: Number(service.discountPrice || service.originalPrice || 0),
image: service.image || null
}))
await config.save()
return res.json({ message: 'خدمات ذخیره شد', config })
} catch (error) {
next(error)
}
}
const updateBookingConfigSchedule = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { weeklySchedule, holidayDates, depositRequired, weeklyRepeatCount } = req.body
if (weeklySchedule !== undefined) {
if (!Array.isArray(weeklySchedule)) {
return bookingError(res, 422, 'ساعات کاری نامعتبر است')
}
for (const entry of weeklySchedule) {
if (!WEEK_DAYS.includes(entry.day)) {
return bookingError(res, 422, 'روز هفته نامعتبر است')
}
}
config.weeklySchedule = weeklySchedule
}
if (holidayDates !== undefined) {
if (!Array.isArray(holidayDates)) {
return bookingError(res, 422, 'روزهای تعطیل نامعتبر است')
}
config.holidayDates = holidayDates.map((d) => new Date(d))
}
if (depositRequired !== undefined) {
config.depositRequired = Boolean(depositRequired)
}
if (weeklyRepeatCount !== undefined) {
if (weeklyRepeatCount === null || weeklyRepeatCount === '') {
config.weeklyRepeatCount = null
} else {
const count = Number(weeklyRepeatCount)
if (!Number.isInteger(count) || count < 1) {
return bookingError(res, 422, 'تعداد تکرار هفته نامعتبر است')
}
config.weeklyRepeatCount = count
}
}
await config.save()
return res.json({ message: 'ساعات کاری ذخیره شد', config })
} catch (error) {
next(error)
}
}
const updateBookingConfigStatus = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
const { status } = req.body
if (!['active', 'inactive'].includes(status)) {
return bookingError(res, 422, 'وضعیت نامعتبر است')
}
config.status = status
// فعال‌سازی/تمدید دستی: شمارش تکرار هفتگی از همین لحظه دوباره شروع میشه
if (status === 'active' && config.weeklyRepeatCount) {
config.repeatStartedAt = new Date()
}
await config.save()
return res.json({ message: 'وضعیت رزرو به‌روزرسانی شد', config })
} catch (error) {
next(error)
}
}
const submitBookingConfig = async (req, res, next) => {
try {
const { config, error } = await findOwnedConfig(req.params.id, req.user._id)
if (error === 'NOT_FOUND') return bookingError(res, 404, 'رزرو یافت نشد')
if (error === 'FORBIDDEN') return bookingError(res, 403, 'شما مالک این رزرو نیستید')
if (!config.name || !config.services?.length || !config.weeklySchedule?.length) {
return bookingError(res, 422, 'اطلاعات رزرو ناقص است')
}
config.status = 'active'
if (config.weeklyRepeatCount) {
config.repeatStartedAt = new Date()
}
await config.save()
return res.json({ message: 'رزرو با موفقیت ثبت شد', config })
} catch (error) {
next(error)
}
}
module.exports = {
createDraftBookingConfig,
getMyBookingConfigs,
getBookingConfigById,
updateBookingConfigServices,
updateBookingConfigSchedule,
updateBookingConfigStatus,
submitBookingConfig
}

View File

@@ -0,0 +1,299 @@
const BookingConfigModel = require('../../../models/BookingConfigModel')
const BookingModel = require('../../../models/BookingModel')
const { notifyUser } = require('../../../utils/notifyUser')
const JS_DAY_TO_KEY = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
function bookingError (res, status, message) {
return res.status(status).json({ error: true, message })
}
function toDateOnlyString (date) {
return new Date(date).toISOString().slice(0, 10)
}
/** null if the "تکرار هفته" window never ends (or isn't set); otherwise the Date it stops repeating. */
function getRepeatExpiresAt (config) {
if (!config.weeklyRepeatCount || !config.repeatStartedAt) return null
return new Date(
new Date(config.repeatStartedAt).getTime() + config.weeklyRepeatCount * 7 * 24 * 60 * 60 * 1000
)
}
/** Public: the buyer-facing view of a user's active booking config, for their profile page. */
const getPublicBookingConfig = async (req, res, next) => {
try {
const config = await BookingConfigModel.findOne({
owner: req.params.userId,
status: 'active'
})
if (!config) return res.json({ config: null })
return res.json({ config, repeatExpiresAt: getRepeatExpiresAt(config) })
} catch (error) {
next(error)
}
}
/** Free time slots for one date: this weekday's ranges, minus holidays, minus already-booked ranges. */
const getAvailability = async (req, res, next) => {
try {
const { configId, date } = req.query
if (!configId || !date) {
return bookingError(res, 422, 'اطلاعات درخواست ناقص است')
}
const config = await BookingConfigModel.findOne({ _id: configId, status: 'active' })
if (!config) return bookingError(res, 404, 'رزرو یافت نشد')
const targetDate = new Date(date)
const isHoliday = (config.holidayDates || []).some(
(d) => toDateOnlyString(d) === toDateOnlyString(targetDate)
)
if (isHoliday) return res.json({ slots: [] })
const repeatExpiresAt = getRepeatExpiresAt(config)
if (repeatExpiresAt && targetDate >= repeatExpiresAt) return res.json({ slots: [] })
const dayKey = JS_DAY_TO_KEY[targetDate.getDay()]
const dayEntry = (config.weeklySchedule || []).find((entry) => entry.day === dayKey)
if (!dayEntry || !dayEntry.ranges?.length) return res.json({ slots: [] })
const startOfDay = new Date(targetDate)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(targetDate)
endOfDay.setHours(23, 59, 59, 999)
const existingBookings = await BookingModel.find({
bookingConfig: configId,
date: { $gte: startOfDay, $lte: endOfDay },
status: { $ne: 'cancelled' }
})
const bookedRanges = new Set(
existingBookings.map((b) => `${b.startTime}-${b.endTime}`)
)
const slots = dayEntry.ranges.filter(
(range) => !bookedRanges.has(`${range.start_time}-${range.end_time}`)
)
return res.json({ slots })
} catch (error) {
next(error)
}
}
/** Creates a booking. Confirmed immediately if no deposit required, otherwise pending_payment. */
const createBooking = async (req, res, next) => {
try {
const { configId, serviceId, date, startTime, endTime } = req.body
if (!configId || !serviceId || !date || !startTime || !endTime) {
return bookingError(res, 422, 'اطلاعات رزرو ناقص است')
}
const config = await BookingConfigModel.findOne({ _id: configId, status: 'active' })
if (!config) return bookingError(res, 404, 'رزرو یافت نشد')
const service = (config.services || []).find((s) => s.serviceId === serviceId)
if (!service) return bookingError(res, 422, 'خدمت یافت نشد')
const targetDate = new Date(date)
const isHoliday = (config.holidayDates || []).some(
(d) => toDateOnlyString(d) === toDateOnlyString(targetDate)
)
if (isHoliday) return bookingError(res, 422, 'این روز تعطیل است')
const repeatExpiresAt = getRepeatExpiresAt(config)
if (repeatExpiresAt && targetDate >= repeatExpiresAt) {
return bookingError(res, 422, 'مهلت تکرار هفتگی این رزرو به پایان رسیده است')
}
const dayKey = JS_DAY_TO_KEY[targetDate.getDay()]
const dayEntry = (config.weeklySchedule || []).find((entry) => entry.day === dayKey)
const validRange = dayEntry?.ranges?.some(
(r) => r.start_time === startTime && r.end_time === endTime
)
if (!validRange) return bookingError(res, 422, 'این بازه زمانی معتبر نیست')
const startOfDay = new Date(targetDate)
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date(targetDate)
endOfDay.setHours(23, 59, 59, 999)
const alreadyBooked = await BookingModel.findOne({
bookingConfig: configId,
date: { $gte: startOfDay, $lte: endOfDay },
startTime,
endTime,
status: { $ne: 'cancelled' }
})
if (alreadyBooked) return bookingError(res, 422, 'این بازه زمانی قبلاً رزرو شده است')
const depositRequired = Boolean(config.depositRequired)
const depositAmount = depositRequired
? Math.round(service.price * 0.3)
: 0
const booking = await BookingModel.create({
bookingConfig: config._id,
provider: config.owner,
buyer: req.user._id,
serviceId,
serviceTitle: service.title,
servicePrice: service.price,
date: targetDate,
startTime,
endTime,
depositRequired,
depositAmount,
status: depositRequired ? 'pending_payment' : 'confirmed'
})
if (!depositRequired) {
await notifyUser(req.app.get('io'), config.owner, {
type: 'booking',
title: 'رزرو جدید',
description: `خدمت «${service.title}» در تاریخ ${toDateOnlyString(targetDate)} ساعت ${startTime} رزرو شد`,
project_post_id: booking._id
})
}
return res.json({ message: 'رزرو ثبت شد', booking })
} catch (error) {
next(error)
}
}
/** Buyer's own bookings, or (role=provider) bookings received against the caller's own configs. */
const getMyBookings = async (req, res, next) => {
try {
const role = req.query.role === 'provider' ? 'provider' : 'buyer'
const filter = role === 'provider'
? { provider: req.user._id }
: { buyer: req.user._id }
const bookings = await BookingModel.find(filter)
.populate('buyer', 'first_name last_name user_name profile_image user_level is_verified verify_badge')
.populate('provider', 'first_name last_name user_name profile_image user_level is_verified verify_badge')
.sort({ date: -1, createdAt: -1 })
return res.json({ bookings })
} catch (error) {
next(error)
}
}
const BOOKING_ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
/** Revenue-over-time + top services + status breakdown for the provider's bookings — backs the "Booking" analytics tab. */
const getBookingAnalytics = async (req, res, next) => {
try {
const userId = req.user._id
const range = BOOKING_ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d'
const days = BOOKING_ANALYTICS_RANGE_DAYS[range]
const since = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : new Date(0)
const confirmedMatch = { provider: userId, status: 'confirmed', date: { $gte: since } }
const [series, totalsAgg, topServicesAgg, statusCounts] = await Promise.all([
BookingModel.aggregate([
{ $match: confirmedMatch },
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$date' } },
revenue: { $sum: '$servicePrice' },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]),
BookingModel.aggregate([
{ $match: confirmedMatch },
{ $group: { _id: null, totalRevenue: { $sum: '$servicePrice' }, totalCount: { $sum: 1 } } }
]),
BookingModel.aggregate([
{ $match: confirmedMatch },
{ $group: { _id: '$serviceTitle', revenue: { $sum: '$servicePrice' }, count: { $sum: 1 } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
]),
BookingModel.aggregate([
{ $match: { provider: userId, date: { $gte: since } } },
{ $group: { _id: '$status', count: { $sum: 1 } } }
])
])
const topItems = topServicesAgg
.filter((row) => row._id)
.map((row) => ({ id: row._id, title: row._id, image: null, metric: row.revenue }))
const statusBreakdown = { pending_payment: 0, confirmed: 0, cancelled: 0 }
for (const row of statusCounts) {
if (row._id in statusBreakdown) statusBreakdown[row._id] = row.count
}
return res.json({
range,
series,
totals: {
totalRevenue: totalsAgg[0]?.totalRevenue || 0,
totalCount: totalsAgg[0]?.totalCount || 0
},
topItems,
rating: null,
statusBreakdown
})
} catch (error) {
next(error)
}
}
/** Provider cancels a reservation made against one of their booking configs. */
const updateBookingStatus = async (req, res, next) => {
try {
const { id } = req.params
const { action } = req.body
if (action !== 'cancel') {
return bookingError(res, 400, 'نوع عملیات نامعتبر است')
}
const booking = await BookingModel.findById(id)
if (!booking) return bookingError(res, 404, 'رزرو یافت نشد')
if (booking.provider.toString() !== req.user._id.toString()) {
return bookingError(res, 403, 'اجازه‌ی تغییر این رزرو را ندارید')
}
if (booking.status === 'cancelled') {
return bookingError(res, 400, 'این رزرو قبلاً لغو شده است')
}
booking.status = 'cancelled'
await booking.save()
await notifyUser(req.app.get('io'), booking.buyer, {
type: 'booking',
title: 'رزرو لغو شد',
description: `رزرو خدمت «${booking.serviceTitle}» در تاریخ ${toDateOnlyString(booking.date)} ساعت ${booking.startTime} توسط ارائه‌دهنده لغو شد`,
project_post_id: booking._id
})
return res.json({
message: 'رزرو لغو شد',
bookingId: String(booking._id),
status: booking.status
})
} catch (error) {
next(error)
}
}
module.exports = {
getPublicBookingConfig,
getAvailability,
createBooking,
getMyBookings,
updateBookingStatus,
getBookingAnalytics
}

View File

@@ -0,0 +1,138 @@
const { default: axios } = require('axios')
const BookingModel = require('../../../models/BookingModel')
const PaymentModel = require('../../../models/PaymentModel')
const { notifyUser } = require('../../../utils/notifyUser')
const { BOOKING_COMMISSION_RATE, creditBookingShare } = require('../../../utils/bookingCommission')
const apiKey =
process.env.ZARINPAL_MERCHANT_ID ||
process.env.MERCHENT_CODE ||
'c7c41e8a-918f-4741-bcd5-58f3bc51db73'
function toDateOnlyString (date) {
return new Date(date).toISOString().slice(0, 10)
}
/** Initiates the 30% deposit payment for a pending booking. */
const initiateBookingPayment = async (req, res) => {
try {
const { bookingId } = req.body
const booking = await BookingModel.findById(bookingId)
if (!booking) {
return res.status(404).json({ error: true, message: 'رزرو یافت نشد' })
}
if (String(booking.buyer) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این رزرو نیستید' })
}
if (booking.status !== 'pending_payment') {
return res.status(400).json({ error: true, message: 'این رزرو در انتظار پرداخت نیست' })
}
const callbackBase =
process.env.APP_SITE_CallBack ||
process.env.APP_URL ||
''
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const response = await axios.post(
'https://www.zarinpal.com/pg/rest/WebGate/PaymentRequest.json',
{
MerchantID: apiKey,
Amount: booking.depositAmount,
Description: `بیعانه رزرو خدمت ${booking.serviceTitle}`,
CallbackURL: `${callbackBase}/bookings/payment-callback/web?bookingId=${booking._id}`
},
{ timeout: 20000 }
)
const data = response.data
if (data.Status === 100 && data.Authority) {
booking.paymentAuthority = data.Authority
await booking.save()
return res.status(200).json({
authority: data.Authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${data.Authority}`
})
}
console.error('initiateBookingPayment ZarinPal error:', data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('initiateBookingPayment error:', error?.response?.data || error?.message || error)
return res.status(500).json({ error: 'Internal server error' })
}
}
const handleBookingPaymentCallback = async (req, res) => {
const { Authority, Status, bookingId } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/booking/payment/failed?bookingId=${bookingId || ''}`
const successRedirect = `${process.env.APP_SITE}/settings/booking/payment/success?bookingId=${bookingId || ''}`
try {
if (Status !== 'OK') return res.redirect(failRedirect)
const booking = await BookingModel.findById(bookingId)
if (!booking) return res.redirect(failRedirect)
if (booking.status === 'confirmed') {
return res.redirect(successRedirect)
}
const verificationResponse = await axios.post(
'https://www.zarinpal.com/pg/rest/WebGate/PaymentVerification.json',
{ MerchantID: apiKey, Authority, Amount: booking.depositAmount }
)
const verificationData = verificationResponse.data
if (verificationData.Status !== 100 && verificationData.Status !== 101) {
await PaymentModel.create({
amount: booking.depositAmount,
status: 'failed',
authority: Authority,
user_id: booking.buyer,
type: 'booking_deposit'
})
return res.redirect(failRedirect)
}
const payment = await PaymentModel.create({
amount: booking.depositAmount,
status: 'successful',
authority: Authority,
user_id: booking.buyer,
type: 'booking_deposit'
})
booking.depositPaid = true
booking.status = 'confirmed'
await booking.save()
const netAmount = Math.round(booking.depositAmount * (1 - BOOKING_COMMISSION_RATE))
await creditBookingShare(
req.app.get('io'),
booking.provider,
netAmount,
payment._id,
`بیعانه رزرو خدمت «${booking.serviceTitle}» (پس از کسر ۵٪ کارمزد)`
)
await notifyUser(req.app.get('io'), booking.provider, {
type: 'booking',
title: 'رزرو جدید',
description: `خدمت «${booking.serviceTitle}» در تاریخ ${toDateOnlyString(booking.date)} ساعت ${booking.startTime} رزرو شد`,
project_post_id: booking._id
})
return res.redirect(successRedirect)
} catch (error) {
console.error('handleBookingPaymentCallback error:', error?.response?.data || error?.message || error)
return res.redirect(failRedirect)
}
}
module.exports = {
initiateBookingPayment,
handleBookingPaymentCallback
}