diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b1463d6..379f674 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -304,10 +304,26 @@ export class AuthService { ownerId: user.id, }); + await this.grantFreeTrial(salon.id, user.id); + const tokens = await this.issueTokens(user); return { ...tokens, salon }; } + private async grantFreeTrial( + salonId: string, + ownerId: string, + ): Promise { + try { + await this.salonSubscriptionsService.startFreeTrial(salonId, ownerId); + } catch (error) { + this.logger.error( + `Failed to grant free trial for salon ${salonId}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + private validateSalonSignupFields(dto: VerifySalonOtpDto): void { const requiredFields: Array = [ 'firstName', diff --git a/src/database/migrations/1722500000000-AddSubscriptionExpiredScenario.ts b/src/database/migrations/1722500000000-AddSubscriptionExpiredScenario.ts new file mode 100644 index 0000000..55791e7 --- /dev/null +++ b/src/database/migrations/1722500000000-AddSubscriptionExpiredScenario.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum'; + +const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.SUBSCRIPTION_EXPIRED]; + +/** + * Only adds the new enum value. Postgres forbids using a newly added enum + * value in the same transaction it was created in, so seeding the actual + * scenario row is handled by the follow-up `SeedSubscriptionExpiredScenario` + * migration. `transaction = false` commits ADD VALUE immediately (required + * by Postgres). + */ +export class AddSubscriptionExpiredScenario1722500000000 implements MigrationInterface { + name = 'AddSubscriptionExpiredScenario1722500000000'; + transaction = false; + + public async up(queryRunner: QueryRunner): Promise { + for (const key of NEW_NOTIFICATION_KEYS) { + await queryRunner.query( + `ALTER TYPE "notification_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`, + ); + } + } + + public async down(): Promise { + // Enum values cannot be dropped without recreating the type; left as-is on downgrade. + } +} diff --git a/src/database/migrations/1722600000000-SeedSubscriptionExpiredScenario.ts b/src/database/migrations/1722600000000-SeedSubscriptionExpiredScenario.ts new file mode 100644 index 0000000..945e72e --- /dev/null +++ b/src/database/migrations/1722600000000-SeedSubscriptionExpiredScenario.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants'; +import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum'; + +const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.SUBSCRIPTION_EXPIRED]; + +/** Seeds the default scenario row for the enum value added in AddSubscriptionExpiredScenario. */ +export class SeedSubscriptionExpiredScenario1722600000000 implements MigrationInterface { + name = 'SeedSubscriptionExpiredScenario1722600000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const key of NEW_NOTIFICATION_KEYS) { + const scenario = NOTIFICATION_SCENARIOS.find((s) => s.key === key); + if (!scenario) continue; + await queryRunner.query( + ` + INSERT INTO notification_scenario_settings (key, title, body, "isActive") + VALUES ($1, $2, $3, true) + ON CONFLICT (key) DO NOTHING + `, + [scenario.key, scenario.defaultTitle, scenario.defaultBody], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "notification_scenario_settings" WHERE "key" IN (${NEW_NOTIFICATION_KEYS.map((key) => `'${key}'`).join(', ')})`, + ); + } +} diff --git a/src/database/migrations/1722700000000-AddFreeTrialSubscription.ts b/src/database/migrations/1722700000000-AddFreeTrialSubscription.ts new file mode 100644 index 0000000..ee91872 --- /dev/null +++ b/src/database/migrations/1722700000000-AddFreeTrialSubscription.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; +import { + FREE_TRIAL_DURATION_DAYS, + FREE_TRIAL_PLAN_CODE, + FREE_TRIAL_SMS_COUNT, +} from '../../subscriptions/constants/subscription.constants'; + +export class AddFreeTrialSubscription1722700000000 implements MigrationInterface { + name = 'AddFreeTrialSubscription1722700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('subscription_plans', [ + new TableColumn({ + name: 'durationDays', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'isTrial', + type: 'boolean', + default: false, + }), + ]); + + await queryRunner.addColumn( + 'salon_subscriptions', + new TableColumn({ + name: 'expiryNotifiedAt', + type: 'timestamp', + isNullable: true, + }), + ); + + await queryRunner.query( + ` + INSERT INTO subscription_plans + (name, code, "durationMonths", "durationDays", price, "freeSmsCount", "isActive", "isTrial") + VALUES + ('دوره آزمایشی رایگان', $1, 0, $2, 0, $3, true, true) + ON CONFLICT (code) DO NOTHING + `, + [FREE_TRIAL_PLAN_CODE, FREE_TRIAL_DURATION_DAYS, FREE_TRIAL_SMS_COUNT], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM subscription_plans WHERE code = '${FREE_TRIAL_PLAN_CODE}'`, + ); + await queryRunner.dropColumn('salon_subscriptions', 'expiryNotifiedAt'); + await queryRunner.dropColumn('subscription_plans', 'isTrial'); + await queryRunner.dropColumn('subscription_plans', 'durationDays'); + } +} diff --git a/src/notifications/constants/notification-scenarios.constants.ts b/src/notifications/constants/notification-scenarios.constants.ts index 027c80a..5f0f984 100644 --- a/src/notifications/constants/notification-scenarios.constants.ts +++ b/src/notifications/constants/notification-scenarios.constants.ts @@ -299,6 +299,35 @@ export const NOTIFICATION_SCENARIOS: NotificationScenarioDefinition[] = [ }, ], }, + { + key: NotificationScenarioKey.SUBSCRIPTION_EXPIRED, + label: 'پایان دوره اشتراک یا آزمایشی', + description: + 'اعلان برای سالن هنگامی که دوره آزمایشی رایگان یا اشتراک خریداری‌شده به پایان می‌رسد', + defaultTitle: 'دوره {planLabel} شما به پایان رسید', + defaultBody: + '{planLabel} سالن {salonName} در تاریخ {endDate} به پایان رسید. برای ادامه استفاده از امکانات و ارسال پیامک، اشتراک خود را تهیه کنید.', + variables: [ + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'planLabel', + label: 'نوع دوره', + description: 'دوره آزمایشی رایگان یا نام پلن خریداری‌شده', + sampleValue: 'دوره آزمایشی رایگان', + }, + { + key: 'endDate', + label: 'تاریخ پایان', + description: 'تاریخ پایان دوره', + sampleValue: '۱۴۰۴/۰۴/۲۰', + }, + ], + }, ]; export const NOTIFICATION_SCENARIO_MAP = new Map( diff --git a/src/notifications/enums/notification-scenario-key.enum.ts b/src/notifications/enums/notification-scenario-key.enum.ts index bb6dd96..24a7804 100644 --- a/src/notifications/enums/notification-scenario-key.enum.ts +++ b/src/notifications/enums/notification-scenario-key.enum.ts @@ -6,4 +6,5 @@ export enum NotificationScenarioKey { CAMPAIGN_SENT = 'campaign_sent', CAMPAIGN_FAILED = 'campaign_failed', DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted', + SUBSCRIPTION_EXPIRED = 'subscription_expired', } diff --git a/src/notifications/salon-notifications.service.ts b/src/notifications/salon-notifications.service.ts index f0956b5..af93a34 100644 --- a/src/notifications/salon-notifications.service.ts +++ b/src/notifications/salon-notifications.service.ts @@ -158,6 +158,28 @@ export class SalonNotificationsService { }); } + async notifySubscriptionExpired(params: { + salonId: string; + salonOwnerId: string; + salonName: string; + planLabel: string; + endDate: Date; + }): Promise { + await this.safeCreate({ + salonId: params.salonId, + userId: params.salonOwnerId, + key: NotificationScenarioKey.SUBSCRIPTION_EXPIRED, + variables: { + salonName: params.salonName, + planLabel: params.planLabel, + endDate: params.endDate.toLocaleDateString('fa-IR'), + }, + link: '/subscription', + relatedEntityType: 'subscription', + relatedEntityId: null, + }); + } + private async notifyForReserve( reserveId: string, key: NotificationScenarioKey, diff --git a/src/subscriptions/constants/subscription.constants.ts b/src/subscriptions/constants/subscription.constants.ts index 105be9f..aa6b0f2 100644 --- a/src/subscriptions/constants/subscription.constants.ts +++ b/src/subscriptions/constants/subscription.constants.ts @@ -1 +1,5 @@ export const SMS_ROLLOVER_GRACE_DAYS = 30; + +export const FREE_TRIAL_PLAN_CODE = 'free-trial'; +export const FREE_TRIAL_DURATION_DAYS = 7; +export const FREE_TRIAL_SMS_COUNT = 100; diff --git a/src/subscriptions/entities/salon-subscription.entity.ts b/src/subscriptions/entities/salon-subscription.entity.ts index edc3c94..b170874 100644 --- a/src/subscriptions/entities/salon-subscription.entity.ts +++ b/src/subscriptions/entities/salon-subscription.entity.ts @@ -52,6 +52,9 @@ export class SalonSubscription { @Column({ type: 'int' }) freeSmsRemaining: number; + @Column({ type: 'timestamp', nullable: true }) + expiryNotifiedAt: Date | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/subscriptions/entities/subscription-plan.entity.ts b/src/subscriptions/entities/subscription-plan.entity.ts index da13a1c..8639345 100644 --- a/src/subscriptions/entities/subscription-plan.entity.ts +++ b/src/subscriptions/entities/subscription-plan.entity.ts @@ -20,6 +20,9 @@ export class SubscriptionPlan { @Column({ type: 'int' }) durationMonths: number; + @Column({ type: 'int', nullable: true }) + durationDays: number | null; + @Column({ type: 'bigint' }) price: string; @@ -29,6 +32,9 @@ export class SubscriptionPlan { @Column({ default: true }) isActive: boolean; + @Column({ default: false }) + isTrial: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/src/subscriptions/salon-subscriptions.service.ts b/src/subscriptions/salon-subscriptions.service.ts index 03959d0..4bbbec0 100644 --- a/src/subscriptions/salon-subscriptions.service.ts +++ b/src/subscriptions/salon-subscriptions.service.ts @@ -42,6 +42,9 @@ export class SalonSubscriptionsService { } const plan = await this.subscriptionPlansService.findActiveById(planId); + if (plan.isTrial) { + throw new BadRequestException('This plan cannot be purchased'); + } const now = new Date(); const rolloverSms = await this.calculateSmsRollover(salonId, now); const freeSmsGranted = plan.freeSmsCount + rolloverSms; @@ -64,6 +67,64 @@ export class SalonSubscriptionsService { }); } + /** + * Grants a new salon owner a one-time 7-day free trial with a limited + * number of free SMS. Silently no-ops if the owner has ever had any + * subscription before (trial or paid), to prevent abuse via creating + * extra salons. Failures are swallowed by the caller so signup never + * fails because of trial provisioning. + */ + async startFreeTrial( + salonId: string, + ownerId: string, + ): Promise { + const ownerSubscriptionCount = await this.subscriptionsRepository + .createQueryBuilder('subscription') + .innerJoin('subscription.salon', 'salon') + .where('salon.ownerId = :ownerId', { ownerId }) + .getCount(); + + if (ownerSubscriptionCount > 0) { + return null; + } + + const plan = await this.subscriptionPlansService.findTrialPlan(); + const now = new Date(); + + const subscription = this.subscriptionsRepository.create({ + salonId, + planId: plan.id, + status: SalonSubscriptionStatus.ACTIVE, + startDate: now, + endDate: this.addDays(now, plan.durationDays ?? 7), + pricePaid: '0', + freeSmsGranted: plan.freeSmsCount, + freeSmsRemaining: plan.freeSmsCount, + }); + + return this.subscriptionsRepository.save(subscription); + } + + /** Subscriptions whose endDate has passed but no expiry notification was sent yet. */ + async findSubscriptionsPendingExpiryNotification(): Promise< + SalonSubscription[] + > { + return this.subscriptionsRepository + .createQueryBuilder('subscription') + .innerJoinAndSelect('subscription.salon', 'salon') + .innerJoinAndSelect('salon.owner', 'owner') + .innerJoinAndSelect('subscription.plan', 'plan') + .where('subscription.endDate <= :now', { now: new Date() }) + .andWhere('subscription.expiryNotifiedAt IS NULL') + .getMany(); + } + + async markExpiryNotified(id: string): Promise { + await this.subscriptionsRepository.update(id, { + expiryNotifiedAt: new Date(), + }); + } + async validateSubscriptionPurchase( salonId: string, planId: string, @@ -74,7 +135,10 @@ export class SalonSubscriptionsService { throw new ForbiddenException(); } - await this.subscriptionPlansService.findActiveById(planId); + const plan = await this.subscriptionPlansService.findActiveById(planId); + if (plan.isTrial) { + throw new BadRequestException('This plan cannot be purchased'); + } const activeSubscription = await this.findActiveSubscription(salonId); if (activeSubscription) { diff --git a/src/subscriptions/subscription-automation.service.ts b/src/subscriptions/subscription-automation.service.ts new file mode 100644 index 0000000..bf5f9ac --- /dev/null +++ b/src/subscriptions/subscription-automation.service.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { SalonNotificationsService } from '../notifications/salon-notifications.service'; +import { SmsService } from '../sms/sms.service'; +import { SalonSubscriptionsService } from './salon-subscriptions.service'; + +@Injectable() +export class SubscriptionAutomationService { + private readonly logger = new Logger(SubscriptionAutomationService.name); + + constructor( + private readonly salonSubscriptionsService: SalonSubscriptionsService, + private readonly salonNotificationsService: SalonNotificationsService, + private readonly smsService: SmsService, + ) {} + + /** + * Detects subscriptions (trial or paid) whose validity period just ended + * and, once per subscription, notifies the salon owner in-app and by SMS + * that they need to purchase a plan to keep using the product. Access is + * already blocked automatically by `ActiveSubscriptionGuard` once the + * subscription's `endDate` has passed, independent of this job. + */ + @Cron(CronExpression.EVERY_DAY_AT_10AM, { + name: 'subscription-expiry-notifications', + }) + async notifyExpiredSubscriptions(): Promise { + const subscriptions = + await this.salonSubscriptionsService.findSubscriptionsPendingExpiryNotification(); + + let notifiedCount = 0; + + for (const subscription of subscriptions) { + try { + await this.salonSubscriptionsService.markExpiryNotified( + subscription.id, + ); + + const planLabel = subscription.plan?.isTrial + ? 'دوره آزمایشی رایگان' + : `اشتراک ${subscription.plan?.name ?? ''}`.trim(); + + await this.salonNotificationsService.notifySubscriptionExpired({ + salonId: subscription.salonId, + salonOwnerId: subscription.salon.ownerId, + salonName: subscription.salon.name, + planLabel, + endDate: subscription.endDate, + }); + + const ownerMobile = subscription.salon.owner?.mobile; + if (ownerMobile) { + await this.smsService.sendSms( + ownerMobile, + `${planLabel} سالن ${subscription.salon.name} به پایان رسید. برای ادامه استفاده از امکانات و ارسال پیامک، همین حالا اشتراک تهیه کنید.`, + ); + } + + notifiedCount += 1; + } catch (error) { + this.logger.error( + `Failed to send expiry notification for subscription ${subscription.id}`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + if (notifiedCount > 0) { + this.logger.log( + `Sent expiry notifications for ${notifiedCount} subscription(s)`, + ); + } + + return notifiedCount; + } +} diff --git a/src/subscriptions/subscription-plans.service.ts b/src/subscriptions/subscription-plans.service.ts index fec26b0..6c1fe01 100644 --- a/src/subscriptions/subscription-plans.service.ts +++ b/src/subscriptions/subscription-plans.service.ts @@ -22,11 +22,21 @@ export class SubscriptionPlansService { findAll(activeOnly = false): Promise { return this.plansRepository.find({ - where: activeOnly ? { isActive: true } : undefined, + where: { isActive: activeOnly ? true : undefined, isTrial: false }, order: { durationMonths: 'ASC' }, }); } + async findTrialPlan(): Promise { + const plan = await this.plansRepository.findOne({ + where: { isTrial: true, isActive: true }, + }); + if (!plan) { + throw new NotFoundException('Free trial plan is not configured'); + } + return plan; + } + async findOne(id: string): Promise { const plan = await this.plansRepository.findOne({ where: { id } }); if (!plan) { diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts index 7b84d25..aeb705b 100644 --- a/src/subscriptions/subscriptions.module.ts +++ b/src/subscriptions/subscriptions.module.ts @@ -3,8 +3,10 @@ import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PaymentsModule } from '../payments/payments.module'; import { SalonsModule } from '../salons/salons.module'; +import { SmsModule } from '../sms/sms.module'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSubscription } from './entities/salon-subscription.entity'; import { SmsPackage } from './entities/sms-package.entity'; @@ -14,6 +16,7 @@ import { SalonSubscriptionsController } from './salon-subscriptions.controller'; import { SalonSubscriptionsService } from './salon-subscriptions.service'; import { SmsPackagesController } from './sms-packages.controller'; import { SmsPackagesService } from './sms-packages.service'; +import { SubscriptionAutomationService } from './subscription-automation.service'; import { SubscriptionPlansController } from './subscription-plans.controller'; import { SubscriptionPlansService } from './subscription-plans.service'; @@ -28,6 +31,8 @@ import { SubscriptionPlansService } from './subscription-plans.service'; SalonsModule, AuthorizationModule, AuthGuardsModule, + NotificationsModule, + SmsModule, forwardRef(() => PaymentsModule), ], controllers: [ @@ -39,6 +44,7 @@ import { SubscriptionPlansService } from './subscription-plans.service'; SubscriptionPlansService, SmsPackagesService, SalonSubscriptionsService, + SubscriptionAutomationService, { provide: APP_GUARD, useClass: ActiveSubscriptionGuard,