This commit is contained in:
2026-07-12 19:08:30 +03:30
parent 9202d1b2ca
commit 4048e3424a
14 changed files with 352 additions and 2 deletions
+16
View File
@@ -304,10 +304,26 @@ export class AuthService {
ownerId: user.id, ownerId: user.id,
}); });
await this.grantFreeTrial(salon.id, user.id);
const tokens = await this.issueTokens(user); const tokens = await this.issueTokens(user);
return { ...tokens, salon }; return { ...tokens, salon };
} }
private async grantFreeTrial(
salonId: string,
ownerId: string,
): Promise<void> {
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 { private validateSalonSignupFields(dto: VerifySalonOtpDto): void {
const requiredFields: Array<keyof VerifySalonOtpDto> = [ const requiredFields: Array<keyof VerifySalonOtpDto> = [
'firstName', 'firstName',
@@ -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<void> {
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<void> {
// Enum values cannot be dropped without recreating the type; left as-is on downgrade.
}
}
@@ -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<void> {
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<void> {
await queryRunner.query(
`DELETE FROM "notification_scenario_settings" WHERE "key" IN (${NEW_NOTIFICATION_KEYS.map((key) => `'${key}'`).join(', ')})`,
);
}
}
@@ -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<void> {
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<void> {
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');
}
}
@@ -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( export const NOTIFICATION_SCENARIO_MAP = new Map(
@@ -6,4 +6,5 @@ export enum NotificationScenarioKey {
CAMPAIGN_SENT = 'campaign_sent', CAMPAIGN_SENT = 'campaign_sent',
CAMPAIGN_FAILED = 'campaign_failed', CAMPAIGN_FAILED = 'campaign_failed',
DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted', DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted',
SUBSCRIPTION_EXPIRED = 'subscription_expired',
} }
@@ -158,6 +158,28 @@ export class SalonNotificationsService {
}); });
} }
async notifySubscriptionExpired(params: {
salonId: string;
salonOwnerId: string;
salonName: string;
planLabel: string;
endDate: Date;
}): Promise<void> {
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( private async notifyForReserve(
reserveId: string, reserveId: string,
key: NotificationScenarioKey, key: NotificationScenarioKey,
@@ -1 +1,5 @@
export const SMS_ROLLOVER_GRACE_DAYS = 30; 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;
@@ -52,6 +52,9 @@ export class SalonSubscription {
@Column({ type: 'int' }) @Column({ type: 'int' })
freeSmsRemaining: number; freeSmsRemaining: number;
@Column({ type: 'timestamp', nullable: true })
expiryNotifiedAt: Date | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -20,6 +20,9 @@ export class SubscriptionPlan {
@Column({ type: 'int' }) @Column({ type: 'int' })
durationMonths: number; durationMonths: number;
@Column({ type: 'int', nullable: true })
durationDays: number | null;
@Column({ type: 'bigint' }) @Column({ type: 'bigint' })
price: string; price: string;
@@ -29,6 +32,9 @@ export class SubscriptionPlan {
@Column({ default: true }) @Column({ default: true })
isActive: boolean; isActive: boolean;
@Column({ default: false })
isTrial: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -42,6 +42,9 @@ export class SalonSubscriptionsService {
} }
const plan = await this.subscriptionPlansService.findActiveById(planId); const plan = await this.subscriptionPlansService.findActiveById(planId);
if (plan.isTrial) {
throw new BadRequestException('This plan cannot be purchased');
}
const now = new Date(); const now = new Date();
const rolloverSms = await this.calculateSmsRollover(salonId, now); const rolloverSms = await this.calculateSmsRollover(salonId, now);
const freeSmsGranted = plan.freeSmsCount + rolloverSms; 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<SalonSubscription | null> {
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<void> {
await this.subscriptionsRepository.update(id, {
expiryNotifiedAt: new Date(),
});
}
async validateSubscriptionPurchase( async validateSubscriptionPurchase(
salonId: string, salonId: string,
planId: string, planId: string,
@@ -74,7 +135,10 @@ export class SalonSubscriptionsService {
throw new ForbiddenException(); 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); const activeSubscription = await this.findActiveSubscription(salonId);
if (activeSubscription) { if (activeSubscription) {
@@ -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<number> {
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;
}
}
@@ -22,11 +22,21 @@ export class SubscriptionPlansService {
findAll(activeOnly = false): Promise<SubscriptionPlan[]> { findAll(activeOnly = false): Promise<SubscriptionPlan[]> {
return this.plansRepository.find({ return this.plansRepository.find({
where: activeOnly ? { isActive: true } : undefined, where: { isActive: activeOnly ? true : undefined, isTrial: false },
order: { durationMonths: 'ASC' }, order: { durationMonths: 'ASC' },
}); });
} }
async findTrialPlan(): Promise<SubscriptionPlan> {
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<SubscriptionPlan> { async findOne(id: string): Promise<SubscriptionPlan> {
const plan = await this.plansRepository.findOne({ where: { id } }); const plan = await this.plansRepository.findOne({ where: { id } });
if (!plan) { if (!plan) {
@@ -3,8 +3,10 @@ import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PaymentsModule } from '../payments/payments.module'; import { PaymentsModule } from '../payments/payments.module';
import { SalonsModule } from '../salons/salons.module'; import { SalonsModule } from '../salons/salons.module';
import { SmsModule } from '../sms/sms.module';
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
import { SalonSubscription } from './entities/salon-subscription.entity'; import { SalonSubscription } from './entities/salon-subscription.entity';
import { SmsPackage } from './entities/sms-package.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 { SalonSubscriptionsService } from './salon-subscriptions.service';
import { SmsPackagesController } from './sms-packages.controller'; import { SmsPackagesController } from './sms-packages.controller';
import { SmsPackagesService } from './sms-packages.service'; import { SmsPackagesService } from './sms-packages.service';
import { SubscriptionAutomationService } from './subscription-automation.service';
import { SubscriptionPlansController } from './subscription-plans.controller'; import { SubscriptionPlansController } from './subscription-plans.controller';
import { SubscriptionPlansService } from './subscription-plans.service'; import { SubscriptionPlansService } from './subscription-plans.service';
@@ -28,6 +31,8 @@ import { SubscriptionPlansService } from './subscription-plans.service';
SalonsModule, SalonsModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
NotificationsModule,
SmsModule,
forwardRef(() => PaymentsModule), forwardRef(() => PaymentsModule),
], ],
controllers: [ controllers: [
@@ -39,6 +44,7 @@ import { SubscriptionPlansService } from './subscription-plans.service';
SubscriptionPlansService, SubscriptionPlansService,
SmsPackagesService, SmsPackagesService,
SalonSubscriptionsService, SalonSubscriptionsService,
SubscriptionAutomationService,
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ActiveSubscriptionGuard, useClass: ActiveSubscriptionGuard,