import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; import { PluginCatalogItem, PluginCatalogPrice, PluginPurchaseStatus, Prisma, ProductType, } from '@prisma/client'; import axios from 'axios'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { CentralSmsService } from '../notifications/central-sms.service'; import { ProgramSettingsService } from '../program-settings/program-settings.service'; import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys'; import { addIranMonths, addIranPlanExpiry, formatIranDate, iranDaysUntil, } from '../common/iran-time.util'; import { daysToPlanMonths, inferSettingKeyFromName, PLUGIN_CATALOG_SEED, PLUGIN_STORE_DURATIONS, } from './plugin-catalog.seed'; import { ApprovePurchaseDto, CreateCatalogSettingDto, CreatePurchaseRequestDto, SaveCatalogItemDto, } from './dto/plugin-store.dto'; type CatalogWithPrices = PluginCatalogItem & { prices: PluginCatalogPrice[] }; @Injectable() export class PluginStoreService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PluginStoreService.name); private timer: NodeJS.Timeout | null = null; private mappedOnce = false; constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly notify: AdminNotifyService, private readonly sms: CentralSmsService, private readonly programSettings: ProgramSettingsService, private readonly config: ConfigService, ) {} onModuleInit() { void this.bootstrap(); setTimeout(() => void this.runKhanegiExpiry(), 25_000); this.timer = setInterval(() => void this.runKhanegiExpiry(), 6 * 60 * 60 * 1000); } onModuleDestroy() { if (this.timer) clearInterval(this.timer); } private async bootstrap() { try { await this.seedCatalog(); await this.programSettings.refreshExtraOwnedKeys(); await this.mapExistingKhanegiPlugins(); } catch (error: any) { this.logger.warn(`plugin store bootstrap skipped: ${error?.message || error}`); } } async seedCatalog() { for (const row of PLUGIN_CATALOG_SEED) { const existing = await this.prisma.pluginCatalogItem.findUnique({ where: { settingKey: row.settingKey }, }); if (existing) continue; await this.prisma.pluginCatalogItem.create({ data: { settingKey: row.settingKey, titleFa: row.titleFa, description: row.description, inStore: true, prices: { create: PLUGIN_STORE_DURATIONS.map((durationDays) => ({ durationDays, enabled: false, originalPriceRials: 0, finalPriceRials: 0, })), }, }, }); } } async mapExistingKhanegiPlugins() { if (this.mappedOnce) return; const rows = await this.prisma.pluginSubscription.findMany({ where: { settingKey: null, tenant: { product: ProductType.khanegi }, }, include: { tenant: { select: { product: true } } }, }); const catalog = await this.prisma.pluginCatalogItem.findMany({ select: { settingKey: true, titleFa: true }, }); const byTitle = new Map(catalog.map((c) => [c.titleFa, c.settingKey])); for (const row of rows) { const key = byTitle.get(row.name.trim()) || inferSettingKeyFromName(row.name); if (!key) continue; await this.prisma.pluginSubscription.update({ where: { id: row.id }, data: { settingKey: key }, }); } this.mappedOnce = true; } inferKey(name: string) { return inferSettingKeyFromName(name); } async isStoreEnabled(tenantId: string): Promise { if (!tenantId) return false; try { const row = await this.prisma.setting.findUnique({ where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, select: { value: true }, }); return row?.value === 'true'; } catch (error: any) { this.logger.warn(`isStoreEnabled ${tenantId}: ${error?.message || error}`); return false; } } async storeEnabledMap(tenantIds: string[]): Promise> { const map = new Map(); if (!tenantIds.length) return map; try { const rows = await this.prisma.setting.findMany({ where: { key: PLUGIN_STORE_SETTING_KEY, tenantId: { in: tenantIds } }, select: { tenantId: true, value: true }, }); for (const row of rows) { map.set(row.tenantId, row.value === 'true'); } } catch (error: any) { this.logger.warn(`storeEnabledMap: ${error?.message || error}`); } return map; } async setStoreEnabled(tenantId: string, enabled: boolean, userId?: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); if (tenant.product !== ProductType.khanegi) { throw new BadRequestException('فروشگاه افزونه فقط برای خانگی است'); } const value = enabled ? 'true' : 'false'; await this.prisma.setting.upsert({ where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, create: { tenantId, key: PLUGIN_STORE_SETTING_KEY, titleFa: 'فروشگاه افزونه', value, type: 'boolean', canEdit: 0, isExist: true, isActive: 1, }, update: { value, deletedAt: null }, }); await this.audit.log({ action: 'plugin_store.toggle', entityType: 'tenant', entityId: tenantId, tenantId, userId, after: { plugin_store_enabled: enabled } as Prisma.InputJsonValue, }); return { pluginStoreEnabled: enabled }; } async applySettingSwitch(tenantId: string, settingKey: string, on: boolean) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant || tenant.product !== ProductType.khanegi) return; try { await this.programSettings.setOwnedSwitch(tenantId, settingKey, on); } catch (error: any) { this.logger.warn( `setOwnedSwitch ${settingKey}=${on} tenant=${tenantId}: ${error?.message || error}`, ); } } async listCatalogAdmin() { try { await this.seedCatalog(); const items = await this.prisma.pluginCatalogItem.findMany({ orderBy: { titleFa: 'asc' }, include: { prices: { orderBy: { durationDays: 'asc' } } }, }); return items.map((item) => this.toCatalogAdmin(item)); } catch (error: any) { this.logger.warn(`listCatalogAdmin skipped: ${error?.message || error}`); return []; } } async saveCatalogItem(id: string, dto: SaveCatalogItemDto, userId?: string) { const item = await this.prisma.pluginCatalogItem.findUnique({ where: { id }, include: { prices: true }, }); if (!item) throw new NotFoundException('افزونه کاتالوگ یافت نشد'); this.assertPrices(dto.prices); await this.prisma.$transaction(async (tx: any) => { await tx.pluginCatalogItem.update({ where: { id }, data: { ...(dto.inStore !== undefined && { inStore: dto.inStore }), ...(dto.titleFa !== undefined && { titleFa: dto.titleFa.trim() }), ...(dto.description !== undefined && { description: dto.description?.trim() || null, }), }, }); for (const price of dto.prices) { await tx.pluginCatalogPrice.upsert({ where: { catalogItemId_durationDays: { catalogItemId: id, durationDays: price.durationDays, }, }, create: { catalogItemId: id, durationDays: price.durationDays, enabled: price.enabled, originalPriceRials: price.originalPriceRials, finalPriceRials: price.finalPriceRials, }, update: { enabled: price.enabled, originalPriceRials: price.originalPriceRials, finalPriceRials: price.finalPriceRials, }, }); } }); await this.audit.log({ action: 'plugin_catalog.update', entityType: 'plugin_catalog_item', entityId: id, userId, after: { settingKey: item.settingKey } as Prisma.InputJsonValue, }); return this.getCatalogAdmin(id); } async createCatalogSetting(dto: CreateCatalogSettingDto, userId?: string) { const key = dto.settingKey.trim(); const exists = await this.prisma.pluginCatalogItem.findUnique({ where: { settingKey: key }, }); if (exists) throw new ConflictException('این کلید از قبل در کاتالوگ است'); const item = await this.prisma.pluginCatalogItem.create({ data: { settingKey: key, titleFa: dto.titleFa.trim(), description: dto.description?.trim() || null, inStore: dto.inStore !== false, prices: { create: PLUGIN_STORE_DURATIONS.map((durationDays) => ({ durationDays, enabled: false, originalPriceRials: 0, finalPriceRials: 0, })), }, }, include: { prices: true }, }); await this.programSettings.provisionOwnedSwitch({ key, titleFa: dto.titleFa.trim(), description: dto.description?.trim() || '', }); await this.audit.log({ action: 'plugin_catalog.create', entityType: 'plugin_catalog_item', entityId: item.id, userId, after: { settingKey: key } as Prisma.InputJsonValue, }); return this.toCatalogAdmin(item); } async listRequestsAdmin(status?: string) { try { const where: Prisma.PluginPurchaseRequestWhereInput = { tenant: { product: ProductType.khanegi }, }; if (status && ['pending', 'approved', 'rejected', 'cancelled'].includes(status)) { where.status = status as PluginPurchaseStatus; } const rows = await this.prisma.pluginPurchaseRequest.findMany({ where, orderBy: { createdAt: 'desc' }, include: { tenant: { select: { id: true, nameFa: true, domain: true, slug: true, product: true }, }, catalogItem: { select: { id: true, titleFa: true, settingKey: true } }, }, }); return rows.map((row) => this.toRequestRow(row)); } catch (error: any) { this.logger.warn(`listRequestsAdmin skipped: ${error?.message || error}`); return []; } } async approveRequest(id: string, dto: ApprovePurchaseDto, userId?: string) { const req = await this.prisma.pluginPurchaseRequest.findUnique({ where: { id }, include: { tenant: true, catalogItem: true, }, }); if (!req) throw new NotFoundException('درخواست یافت نشد'); if (req.tenant.product !== ProductType.khanegi) { throw new ForbiddenException('فقط درخواست خانگی'); } if (req.status !== 'pending') { throw new BadRequestException('این درخواست قابل تأیید نیست'); } const months = daysToPlanMonths(req.durationDays); const existing = await this.prisma.pluginSubscription.findFirst({ where: { tenantId: req.tenantId, settingKey: req.catalogItem.settingKey, }, orderBy: { expiresAt: 'desc' }, }); const now = new Date(); const from = existing?.expiresAt && existing.expiresAt.getTime() > now.getTime() ? existing.expiresAt : now; const expiresAt = existing?.expiresAt && existing.expiresAt.getTime() > now.getTime() ? addIranMonths(from, months) : addIranPlanExpiry(now, months); const plugin = existing ? await this.prisma.pluginSubscription.update({ where: { id: existing.id }, data: { name: req.catalogItem.titleFa, specs: req.catalogItem.description, settingKey: req.catalogItem.settingKey, planMonths: months, priceRials: req.finalPriceRials, chargedAt: now, expiresAt, isActive: true, autoCutOnExpiry: dto.autoCutOnExpiry, lastAlertDate: null, alertMarks: Prisma.DbNull, }, }) : await this.prisma.pluginSubscription.create({ data: { tenantId: req.tenantId, name: req.catalogItem.titleFa, specs: req.catalogItem.description, settingKey: req.catalogItem.settingKey, planMonths: months, priceRials: req.finalPriceRials, chargedAt: now, expiresAt, isActive: true, autoCutOnExpiry: dto.autoCutOnExpiry, }, }); await this.prisma.pluginCharge.create({ data: { pluginId: plugin.id, chargedAt: now, planMonths: months, priceRials: req.finalPriceRials, expiresAt, note: 'خرید از فروشگاه', createdById: userId, }, }); await this.prisma.pluginPurchaseRequest.update({ where: { id }, data: { status: 'approved', autoCutOnExpiry: dto.autoCutOnExpiry, pluginSubscriptionId: plugin.id, decidedAt: now, }, }); await this.applySettingSwitch(req.tenantId, req.catalogItem.settingKey, true); await this.audit.log({ action: 'plugin_request.approve', entityType: 'plugin_purchase_request', entityId: id, tenantId: req.tenantId, userId, after: { settingKey: req.catalogItem.settingKey, expiresAt: expiresAt.toISOString() } as Prisma.InputJsonValue, }); return { ok: true, expiresAt: expiresAt.toISOString() }; } async rejectRequest(id: string, userId?: string) { const req = await this.prisma.pluginPurchaseRequest.findUnique({ where: { id } }); if (!req) throw new NotFoundException('درخواست یافت نشد'); if (req.status !== 'pending') { throw new BadRequestException('این درخواست قابل رد نیست'); } await this.prisma.pluginPurchaseRequest.update({ where: { id }, data: { status: 'rejected', decidedAt: new Date() }, }); await this.audit.log({ action: 'plugin_request.reject', entityType: 'plugin_purchase_request', entityId: id, tenantId: req.tenantId, userId, }); return { ok: true }; } async remoteStore(tenantId: string, product: string) { await this.assertRemoteKhanegi(tenantId, product); const items = await this.prisma.pluginCatalogItem.findMany({ where: { inStore: true }, orderBy: { titleFa: 'asc' }, include: { prices: { orderBy: { durationDays: 'asc' } } }, }); const subs = await this.prisma.pluginSubscription.findMany({ where: { tenantId, settingKey: { not: null } }, }); const pending = await this.prisma.pluginPurchaseRequest.findMany({ where: { tenantId, status: 'pending' }, select: { catalogItemId: true }, }); const pendingIds = new Set(pending.map((p) => p.catalogItemId)); const now = Date.now(); return { plugin_store_enabled: true, items: items .map((item) => { const enabledPrices = item.prices.filter((p) => p.enabled); if (!enabledPrices.length) return null; const sub = subs .filter((s) => s.settingKey === item.settingKey) .sort((a, b) => (b.expiresAt?.getTime() || 0) - (a.expiresAt?.getTime() || 0))[0]; const daysLeft = sub?.expiresAt ? iranDaysUntil(sub.expiresAt) : null; const hasPending = pendingIds.has(item.id); const lockedByDays = sub?.isActive && sub.expiresAt && sub.expiresAt.getTime() > now && daysLeft != null && daysLeft > 3; return { id: item.id, settingKey: item.settingKey, titleFa: item.titleFa, description: item.description, prices: enabledPrices.map((p) => ({ durationDays: p.durationDays, originalPriceRials: p.originalPriceRials, finalPriceRials: p.finalPriceRials, discounted: p.finalPriceRials < p.originalPriceRials, free: p.finalPriceRials === 0, })), subscription: sub ? { expiresAt: sub.expiresAt?.toISOString() ?? null, daysLeft, isActive: sub.isActive, } : null, canBuy: !hasPending && !lockedByDays, pendingRequest: hasPending, }; }) .filter(Boolean), }; } async remoteListRequests(tenantId: string, product: string) { await this.assertRemoteKhanegi(tenantId, product); const rows = await this.prisma.pluginPurchaseRequest.findMany({ where: { tenantId }, orderBy: { createdAt: 'desc' }, include: { catalogItem: { select: { titleFa: true, settingKey: true } }, tenant: { select: { nameFa: true, domain: true, product: true, slug: true, id: true } }, }, }); return rows.map((row) => this.toRequestRow(row)); } async remoteCreateRequest( tenantId: string, product: string, dto: CreatePurchaseRequestDto, ) { await this.assertRemoteKhanegi(tenantId, product); const item = await this.prisma.pluginCatalogItem.findUnique({ where: { id: dto.catalogItemId }, include: { prices: true }, }); if (!item || !item.inStore) throw new NotFoundException('افزونه در فروشگاه نیست'); const price = item.prices.find((p) => p.durationDays === dto.durationDays && p.enabled); if (!price) throw new BadRequestException('این مدت برای افزونه فعال نیست'); if (price.finalPriceRials !== dto.finalPriceRials) { throw new BadRequestException('قیمت عوض شده؛ صفحه را تازه کنید'); } const existingPending = await this.prisma.pluginPurchaseRequest.findFirst({ where: { tenantId, catalogItemId: item.id, status: 'pending' }, }); if (existingPending) { throw new ConflictException('درخواست در انتظار برای این افزونه وجود دارد'); } const sub = await this.prisma.pluginSubscription.findFirst({ where: { tenantId, settingKey: item.settingKey, isActive: true }, orderBy: { expiresAt: 'desc' }, }); if (sub?.expiresAt && sub.expiresAt.getTime() > Date.now()) { const daysLeft = iranDaysUntil(sub.expiresAt); if (daysLeft > 3) { throw new ConflictException('تا ۳ روز مانده به انقضا نمی‌توان تمدید کرد'); } } const created = await this.prisma.pluginPurchaseRequest.create({ data: { tenantId, catalogItemId: item.id, durationDays: dto.durationDays, originalPriceRials: price.originalPriceRials, finalPriceRials: price.finalPriceRials, status: 'pending', }, include: { catalogItem: { select: { titleFa: true, settingKey: true } }, tenant: { select: { nameFa: true, domain: true } }, }, }); this.notify.notifyAdmins( `درخواست افزونه: «${created.catalogItem.titleFa}» ${dto.durationDays}روزه برای «${created.tenant.nameFa}» (${created.tenant.domain}) — ${price.finalPriceRials.toLocaleString('fa-IR')} ریال`, ); return this.toRequestRow({ ...created, tenant: { id: tenantId, nameFa: created.tenant.nameFa, domain: created.tenant.domain, slug: '', product: ProductType.khanegi, }, } as any); } async remoteCancelRequest(tenantId: string, product: string, requestId: string) { await this.assertRemoteKhanegi(tenantId, product); const req = await this.prisma.pluginPurchaseRequest.findFirst({ where: { id: requestId, tenantId }, }); if (!req) throw new NotFoundException('درخواست یافت نشد'); if (req.status !== 'pending') { throw new BadRequestException('فقط درخواست در انتظار قابل لغو است'); } await this.prisma.pluginPurchaseRequest.update({ where: { id: requestId }, data: { status: 'cancelled', decidedAt: new Date() }, }); return { ok: true }; } async runKhanegiExpiry() { try { const plugins = await this.prisma.pluginSubscription.findMany({ where: { settingKey: { not: null }, expiresAt: { not: null }, tenant: { product: ProductType.khanegi, isActive: true }, }, include: { tenant: { select: { id: true, nameFa: true, domain: true, product: true, nodeServer: true, remoteApiToken: true, }, }, }, }); for (const plugin of plugins) { if (!plugin.expiresAt || !plugin.settingKey) continue; const daysLeft = iranDaysUntil(plugin.expiresAt); const marks = this.readMarks(plugin.alertMarks, plugin.expiresAt); if (daysLeft === 3 || daysLeft === 2 || daysLeft === 1) { const key = String(daysLeft); if (!marks[key]) { await this.smsTenantAdmins( plugin.tenant, `افزونه «${plugin.name}»: ${daysLeft} روز تا پایان اشتراک (انقضا ${formatIranDate(plugin.expiresAt)}).`, ); marks[key] = true; await this.prisma.pluginSubscription.update({ where: { id: plugin.id }, data: { alertMarks: marks as Prisma.InputJsonValue }, }); } } if (daysLeft === 0 && !marks['0']) { this.notify.notifyAdmins( `سررسید افزونه: «${plugin.name}» روی «${plugin.tenant.nameFa}» (${plugin.tenant.domain}) امروز تمام می‌شود.`, ); marks['0'] = true; await this.prisma.pluginSubscription.update({ where: { id: plugin.id }, data: { alertMarks: marks as Prisma.InputJsonValue }, }); } if (plugin.autoCutOnExpiry && daysLeft <= 0 && plugin.isActive) { await this.prisma.pluginSubscription.update({ where: { id: plugin.id }, data: { isActive: false }, }); await this.applySettingSwitch(plugin.tenantId, plugin.settingKey, false); } } } catch (error: any) { this.logger.error(`Khanegi plugin expiry failed: ${error?.message || error}`); } } private async assertRemoteKhanegi(tenantId: string, product: string) { if (product !== 'khanegi') { throw new ForbiddenException('فروشگاه افزونه فقط برای خانگی است'); } if (!tenantId) throw new ForbiddenException('نسخه نامعتبر است'); if (!(await this.isStoreEnabled(tenantId))) { throw new ForbiddenException('فروشگاه افزونه برای این نسخه خاموش است'); } } private assertPrices(prices: SaveCatalogItemDto['prices']) { const seen = new Set(); for (const price of prices) { if (!PLUGIN_STORE_DURATIONS.includes(price.durationDays as any)) { throw new BadRequestException('مدت نامعتبر است'); } if (seen.has(price.durationDays)) { throw new BadRequestException('مدت تکراری است'); } seen.add(price.durationDays); if (price.enabled) { if (price.originalPriceRials == null || price.finalPriceRials == null) { throw new BadRequestException('برای مدت روشن باید قیمت اصلی و نهایی زده شود'); } if (price.originalPriceRials < 0 || price.finalPriceRials < 0) { throw new BadRequestException('قیمت منفی مجاز نیست'); } } } } private toCatalogAdmin(item: CatalogWithPrices) { const priceMap = new Map(item.prices.map((p) => [p.durationDays, p])); return { id: item.id, settingKey: item.settingKey, titleFa: item.titleFa, description: item.description, inStore: item.inStore, prices: PLUGIN_STORE_DURATIONS.map((days) => { const row = priceMap.get(days); return { durationDays: days, enabled: row?.enabled ?? false, originalPriceRials: row?.originalPriceRials ?? 0, finalPriceRials: row?.finalPriceRials ?? 0, }; }), }; } private async getCatalogAdmin(id: string) { const item = await this.prisma.pluginCatalogItem.findUnique({ where: { id }, include: { prices: { orderBy: { durationDays: 'asc' } } }, }); if (!item) throw new NotFoundException('افزونه کاتالوگ یافت نشد'); return this.toCatalogAdmin(item); } private toRequestRow(row: any) { return { id: row.id, tenantId: row.tenantId, tenantName: row.tenant?.nameFa, domain: row.tenant?.domain, catalogItemId: row.catalogItemId, pluginTitle: row.catalogItem?.titleFa, settingKey: row.catalogItem?.settingKey, durationDays: row.durationDays, originalPriceRials: row.originalPriceRials, finalPriceRials: row.finalPriceRials, status: row.status, autoCutOnExpiry: row.autoCutOnExpiry, createdAt: row.createdAt?.toISOString?.() ?? row.createdAt, decidedAt: row.decidedAt?.toISOString?.() ?? row.decidedAt ?? null, }; } private readMarks(raw: unknown, expiresAt: Date): Record { const iso = expiresAt.toISOString(); if (!raw || typeof raw !== 'object') return { _exp: iso }; const obj = raw as Record; if (obj._exp && obj._exp !== iso) return { _exp: iso }; return { ...obj, _exp: iso }; } private async smsTenantAdmins( tenant: { id: string; nodeServer: string; remoteApiToken: string | null; nameFa: string; }, text: string, ) { try { const mobiles = await this.fetchTenantAdminMobiles(tenant); if (!mobiles.length) { this.logger.warn(`no admin mobiles for tenant ${tenant.id}`); return; } for (const mobile of mobiles) { await this.sms.send(mobile, text); } } catch (error: any) { this.logger.warn(`tenant admin SMS failed ${tenant.id}: ${error?.message || error}`); } } private async fetchTenantAdminMobiles(tenant: { nodeServer: string; remoteApiToken: string | null; }): Promise { const base = String(tenant.nodeServer || '').replace(/\/+$/, ''); if (!base) return []; const origin = base.replace(/\/api\/?$/, ''); const url = `${origin}/api/super/admin-mobiles`; const secret = String(this.config.get('SMS_REMOTE_SHARED_SECRET') || '').trim(); const token = String(tenant.remoteApiToken || '').trim(); const bearer = secret || token; if (!bearer) return []; const { data } = await axios.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${bearer}` }, timeout: 8000, validateStatus: () => true, }); const list = Array.isArray(data?.mobiles) ? data.mobiles : []; return list.map((m: unknown) => String(m || '').trim()).filter(Boolean); } }