diff --git a/package.json b/package.json index 19500151..42981802 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@nestjs/mongoose": "^11.0.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.11", + "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^11.0.6", "@types/cookie-parser": "^1.4.9", "@types/passport-google-oauth20": "^2.0.16", @@ -51,7 +52,8 @@ "lint": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" && eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"", "lint:src": "prettier --write \"src/**/*.ts\" && eslint --fix \"src/**/*.ts\"", "lint:test": "prettier --write \"test/**/*.ts\" && eslint --fix \"test/**/*.ts\"", - "seed": "ts-node -r tsconfig-paths/register scripts/seeds/index.ts" + "seed": "ts-node -r tsconfig-paths/register scripts/seeds/index.ts", + "seed:plan": "ts-node -r tsconfig-paths/register scripts/seeds/seed-plan.ts" }, "devDependencies": { "@darraghor/eslint-plugin-nestjs-typed": "^6.4.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c45a7f56..a73253ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.11 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/schedule': + specifier: ^6.0.1 + version: 6.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/swagger': specifier: ^11.0.6 version: 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) @@ -820,6 +823,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.0.1': + resolution: {integrity: sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.7': resolution: {integrity: sha512-t8dNYYMwEeEsrlwc2jbkfwCfXczq4AeNEgx1KVQuJ6wYibXk0ZbXbPdfp8scnEAaQv1grpncNV5gWgzi7ZwbvQ==} peerDependencies: @@ -985,6 +994,9 @@ packages: '@types/jsonwebtoken@9.0.7': resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -1605,6 +1617,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.3.3: + resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==} + engines: {node: '>=18.x'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2535,6 +2551,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -4422,6 +4442,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.3.3 + '@nestjs/schematics@11.0.7(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) @@ -4607,6 +4633,8 @@ snapshots: dependencies: '@types/node': 22.18.6 + '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -5308,6 +5336,11 @@ snapshots: create-require@1.1.1: {} + cron@4.3.3: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6421,6 +6454,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/scripts/seeds/README.md b/scripts/seeds/README.md index 989c6e12..f361138e 100644 --- a/scripts/seeds/README.md +++ b/scripts/seeds/README.md @@ -14,7 +14,15 @@ - **用途**: 创建电话系统测试数据,包括用户、公司和服务 - **运行命令**: `npm run seed:telephony` -### 3. 全部Seed (`seed:all`) +### 3. Plan Seed (`seed:plan`) +- **文件**: `seed-plan-data.ts` +- **用途**: 创建计划数据(FREE, BASIC, PRO计划) +- **运行命令**: `npm run seed:plan` +- **说明**: + - 脚本会检查计划是否已存在,如果存在则更新,如果不存在则创建 + - 需要在 `seed-plan-data.ts` 中更新 UAT 数据(价格、Stripe价格ID、功能等) + +### 4. 全部Seed (`seed:all`) - **用途**: 运行所有seed脚本 - **运行命令**: `npm run seed:all` 或 `npm run seed` @@ -27,6 +35,9 @@ npm run seed:calllog # 只运行telephony数据 npm run seed:telephony + +# 只运行plan数据 +npm run seed:plan ``` ### 运行所有Seed @@ -53,12 +64,23 @@ npm run seed 4. Carpet Cleaning - $100 5. Window Cleaning - $90 +### Plan测试数据 +- 创建三个计划: FREE, BASIC, PRO +- 每个计划包含: + - 名称和层级 + - 定价选项(月度、季度、年度等) + - 功能(通话分钟数、支持级别) + - Stripe价格ID +- **注意**: 使用前需要在 `seed-plan-data.ts` 中更新UAT数据 + ## 注意事项 -1. 运行seed脚本会清除现有的测试数据 -2. 确保MongoDB连接正常 -3. 确保环境变量配置正确 -4. 每个seed脚本都可以独立运行 +1. 运行seed脚本可能会清除现有的测试数据(calllog和telephony seed) +2. Plan seed会更新现有计划,如果计划不存在则创建新计划(不会删除现有计划) +3. 确保MongoDB连接正常 +4. 确保环境变量配置正确 +5. 每个seed脚本都可以独立运行 +6. **Plan Seed使用前**: 请先在 `seed-plan-data.ts` 中更新UAT数据,包括价格、Stripe价格ID和功能描述 ## 文件结构 @@ -67,9 +89,11 @@ scripts/seeds/ ├── index.ts # 主入口文件,导入所有seed ├── seed-calllog.ts # Calllog seed入口 ├── seed-telephony.ts # Telephony seed入口 +├── seed-plan.ts # Plan seed入口 ├── run-calllog-seed.ts # Calllog seed运行脚本 ├── run-telephony-seed.ts # Telephony seed运行脚本 ├── seed-inbox-data.ts # Calllog测试数据 ├── seed-telephony-test-data.ts # Telephony测试数据 +├── seed-plan-data.ts # Plan测试数据 └── README.md # 本文档 ``` \ No newline at end of file diff --git a/scripts/seeds/index.ts b/scripts/seeds/index.ts index 8fc9c39c..e6096e92 100644 --- a/scripts/seeds/index.ts +++ b/scripts/seeds/index.ts @@ -1,2 +1,3 @@ import './seed-calllog'; -import './seed-telephony'; \ No newline at end of file +import './seed-telephony'; +import './seed-plan'; diff --git a/scripts/seeds/seed-plan-data.ts b/scripts/seeds/seed-plan-data.ts new file mode 100644 index 00000000..4d36d23e --- /dev/null +++ b/scripts/seeds/seed-plan-data.ts @@ -0,0 +1,175 @@ +import { connect, connection, model, Schema } from 'mongoose'; + +const MONGODB_URI = + process.env.MONGODB_URI || 'mongodb://localhost:27017/dispatchai'; + +// Plan Schema +const planSchema = new Schema( + { + name: { type: String, required: true, unique: true }, + tier: { + type: String, + required: true, + enum: ['FREE', 'BASIC', 'PRO'], + }, + pricing: [ + { + rrule: { type: String, required: true }, + price: { type: Number, required: true }, + stripePriceId: { type: String, required: true }, + }, + ], + features: { + callMinutes: { type: String, required: true }, + support: { type: String, required: true }, + }, + isActive: { type: Boolean, default: true }, + }, + { timestamps: true }, +); + +// Plan data configuration +// Updated with UAT data +const planData = { + FREE: { + name: 'Free Plan', + tier: 'FREE' as const, + pricing: [ + { + rrule: 'FREQ=MONTHLY;INTERVAL=1', + price: 0, + stripePriceId: 'price_free_monthly', // Update with real Stripe price ID + }, + ], + features: { + callMinutes: '100 minutes', + support: 'Email support', + }, + isActive: true, + }, + BASIC: { + name: 'Basic Plan', + tier: 'BASIC' as const, + pricing: [ + { + rrule: 'FREQ=MONTHLY;INTERVAL=1', + price: 19, + stripePriceId: 'price_1S4yeoR2XtOFzYzKH32PKqPk', + }, + ], + features: { + callMinutes: '100 Min/Month', + support: 'Automatic Summary', + }, + isActive: true, + }, + PRO: { + name: 'Pro Plan', + tier: 'PRO' as const, + pricing: [ + { + rrule: 'FREQ=MONTHLY;INTERVAL=1', + price: 79, + stripePriceId: 'price_1S4yexR2XtOFzYzKm5w8RRrc', + }, + ], + features: { + callMinutes: '1000 Min/Month', + support: 'Automatic Summary + Service Booking', + }, + isActive: true, + }, +}; + +async function seedPlanData() { + try { + await connect(MONGODB_URI); + console.log('✅ Connected to MongoDB'); + + const PlanModel = model('Plan', planSchema); + + // Seed each plan + const plans = ['FREE', 'BASIC', 'PRO'] as const; + + for (const tier of plans) { + const planConfig = planData[tier]; + + // Check if plan already exists + const existingPlan = await PlanModel.findOne({ tier }).exec(); + + if (existingPlan) { + console.log(`🔄 Updating existing ${tier} plan...`); + await PlanModel.findOneAndUpdate( + { tier }, + planConfig, + { + new: true, + upsert: false, + runValidators: true, + }, + ).exec(); + console.log(`✅ Updated ${tier} plan: ${planConfig.name}`); + } else { + console.log(`➕ Creating new ${tier} plan...`); + await PlanModel.create(planConfig); + console.log(`✅ Created ${tier} plan: ${planConfig.name}`); + } + } + + // Display all plans + console.log('\n📋 All Plans:'); + const allPlans = await PlanModel.find({ isActive: true }) + .sort({ tier: 1 }) + .exec(); + + allPlans.forEach(plan => { + console.log(`\n${plan.tier} - ${plan.name}`); + console.log(` Features:`); + if (plan.features) { + console.log(` - Call Minutes: ${plan.features.callMinutes}`); + console.log(` - Support: ${plan.features.support}`); + } else { + console.log(` - Features not available`); + } + console.log(` Pricing:`); + plan.pricing?.forEach(pricing => { + const period = pricing.rrule.includes('INTERVAL=3') + ? 'quarter' + : pricing.rrule.includes('FREQ=YEARLY') + ? 'year' + : 'month'; + console.log( + ` - $${pricing.price}/${period} (Stripe: ${pricing.stripePriceId})`, + ); + }); + }); + + console.log('\n🚀 Plan Data Seeding Complete!'); + } catch (error) { + console.error('❌ Error seeding plan data:', error); + throw error; + } finally { + await connection.close(); + console.log('🔌 Disconnected from MongoDB'); + } +} + +// Export for use in other files +export { seedPlanData }; + +// If running directly +if (require.main === module) { + console.log('🚀 Starting Plan Data Seeding...'); + console.log('====================================='); + + seedPlanData() + .then(() => { + console.log('\n✅ Plan data seeding completed successfully!'); + process.exit(0); + }) + .catch((error: any) => { + console.error('❌ Failed to seed plan data:', error); + process.exit(1); + }); +} + diff --git a/scripts/seeds/seed-plan.ts b/scripts/seeds/seed-plan.ts new file mode 100644 index 00000000..99b476bb --- /dev/null +++ b/scripts/seeds/seed-plan.ts @@ -0,0 +1,19 @@ +import { seedPlanData } from './seed-plan-data'; + +console.log('🚀 Starting Plan Data Seeding...'); +console.log('====================================='); + +seedPlanData() + .then(() => { + console.log('\n✅ Plan data seeding completed successfully!'); + console.log('\n📝 Next Steps:'); + console.log('1. Verify plans in your database'); + console.log('2. Update Stripe price IDs if needed'); + console.log('3. Test plan subscription flows'); + process.exit(0); + }) + .catch((error: any) => { + console.error('❌ Failed to seed plan data:', error); + process.exit(1); + }); + diff --git a/src/modules/app.module.ts b/src/modules/app.module.ts index 00a8055f..5891a870 100755 --- a/src/modules/app.module.ts +++ b/src/modules/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; import { CSRFGuard } from '@/common/guards/csrf.guard'; import { AiHttpModule } from '@/lib/ai/ai-http.module'; @@ -27,6 +28,8 @@ import { SubscriptionModule } from '@/modules/subscription/subscription.module'; import { TelephonyModule } from '@/modules/telephony/telephony.module'; import { TranscriptModule } from '@/modules/transcript/transcript.module'; import { TranscriptChunkModule } from '@/modules/transcript-chunk/transcript-chunk.module'; +import { TwilioPhoneNumberModule } from '@/modules/twilio-phone-number/twilio-phone-number.module'; +import { TwilioPhoneNumberAssignmentModule } from '@/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module'; import { UserModule } from '@/modules/user/user.module'; import { OnboardingModule } from './onboarding/onboarding.module'; @@ -55,6 +58,8 @@ import { OnboardingModule } from './onboarding/onboarding.module'; ServiceLocationMappingModule, TelephonyModule, TwilioModule, + TwilioPhoneNumberModule, + TwilioPhoneNumberAssignmentModule, RedisModule, AiHttpModule, SubscriptionModule, @@ -62,6 +67,7 @@ import { OnboardingModule } from './onboarding/onboarding.module'; UserModule, OnboardingModule, SettingModule, + ScheduleModule.forRoot(), ], providers: [ { diff --git a/src/modules/company/dto/create-company.dto.ts b/src/modules/company/dto/create-company.dto.ts index 098d4c0d..efc46080 100644 --- a/src/modules/company/dto/create-company.dto.ts +++ b/src/modules/company/dto/create-company.dto.ts @@ -1,14 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { - IsBoolean, - IsEmail, - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - ValidateNested, -} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class CreateCompanyDto { @ApiProperty({ diff --git a/src/modules/company/schema/company.schema.ts b/src/modules/company/schema/company.schema.ts index 37e5e9e1..c05a67dc 100644 --- a/src/modules/company/schema/company.schema.ts +++ b/src/modules/company/schema/company.schema.ts @@ -15,7 +15,7 @@ export class Company { user!: User; @Prop() calendar_access_token?: string; - + _id!: Types.ObjectId; } diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts index c874325c..32140151 100644 --- a/src/modules/database/database.module.ts +++ b/src/modules/database/database.module.ts @@ -7,13 +7,17 @@ import { MongooseModule } from '@nestjs/mongoose'; MongooseModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => { + useFactory: (configService: ConfigService) => { // Read MONGODB_URI at runtime, not at module load time // This allows tests to set the URI before the connection is established - const uri = - process.env.MONGODB_URI || - configService.get('MONGODB_URI') || - 'mongodb://localhost:27017/dispatchai'; + const envUri = process.env.MONGODB_URI; + const configUri = configService.get('MONGODB_URI'); + let uri = 'mongodb://localhost:27017/dispatchai'; + if (envUri !== undefined && envUri !== '') { + uri = envUri; + } else if (configUri !== undefined && configUri !== '') { + uri = configUri; + } // In test environment, disable retries to avoid connection error logs const isTest = process.env.NODE_ENV === 'test'; diff --git a/src/modules/google-calendar/calendar-token.controller.ts b/src/modules/google-calendar/calendar-token.controller.ts index ff223f1e..b052092d 100644 --- a/src/modules/google-calendar/calendar-token.controller.ts +++ b/src/modules/google-calendar/calendar-token.controller.ts @@ -1,25 +1,8 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, - Query, - Request, - UseGuards, -} from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CalendarTokenService } from './calendar-token.service'; import { CreateCalendarTokenDto } from './dto/create-calendar-token.dto'; -import { CalendarToken } from './schema/calendar-token.schema'; @ApiTags('calendar-token') @Controller('calendar-token') @@ -30,7 +13,9 @@ export class CalendarTokenController { @ApiResponse({ status: 200, description: 'Token fetched successfully' }) @ApiResponse({ status: 404, description: 'Token not found' }) @Get('user/:userId/valid') - async getValidToken(@Param('userId') userId: string) { + async getValidToken( + @Param('userId') userId: string, + ): Promise<{ accessToken: string }> { return await this.calendarTokenService.getValidToken(userId); } @@ -38,7 +23,9 @@ export class CalendarTokenController { @ApiResponse({ status: 200, description: 'Token refreshed successfully' }) @ApiResponse({ status: 404, description: 'Token not found' }) @Post('user/:userId/refresh') - async refreshToken(@Param('userId') userId: string) { + async refreshToken( + @Param('userId') userId: string, + ): Promise<{ accessToken: string }> { return await this.calendarTokenService.refreshToken(userId); } @@ -48,21 +35,28 @@ export class CalendarTokenController { description: 'Token created/updated successfully', }) @Post() - async createOrUpdateToken(@Body() createDto: CreateCalendarTokenDto) { - return await this.calendarTokenService.createOrUpdateToken(createDto); + async createOrUpdateToken( + @Body() createDto: CreateCalendarTokenDto, + ): Promise<{ message: string }> { + await this.calendarTokenService.createOrUpdateToken(createDto); + return { message: 'Token created/updated successfully' }; } @ApiOperation({ summary: 'Get user calendar token' }) @ApiResponse({ status: 200, description: 'Token fetched successfully' }) @Get('user/:userId') - async getUserToken(@Param('userId') userId: string) { + async getUserToken( + @Param('userId') userId: string, + ): Promise<{ accessToken: string; refreshToken: string } | null> { return await this.calendarTokenService.getUserToken(userId); } @ApiOperation({ summary: 'Delete user calendar token' }) @ApiResponse({ status: 200, description: 'Token deleted successfully' }) @Delete('user/:userId') - async deleteUserToken(@Param('userId') userId: string) { + async deleteUserToken( + @Param('userId') userId: string, + ): Promise<{ message: string }> { await this.calendarTokenService.deleteUserToken(userId); return { message: 'Token deleted' }; } @@ -70,7 +64,9 @@ export class CalendarTokenController { @ApiOperation({ summary: 'Check if token is expiring soon' }) @ApiResponse({ status: 200, description: 'Check result' }) @Get('user/:userId/expiring') - async isTokenExpiringSoon(@Param('userId') userId: string) { + async isTokenExpiringSoon( + @Param('userId') userId: string, + ): Promise<{ isExpiringSoon: boolean }> { const isExpiring = await this.calendarTokenService.isTokenExpiringSoon(userId); return { isExpiringSoon: isExpiring }; diff --git a/src/modules/google-calendar/calendar-token.service.ts b/src/modules/google-calendar/calendar-token.service.ts index 3e8ab77d..f0c384ca 100644 --- a/src/modules/google-calendar/calendar-token.service.ts +++ b/src/modules/google-calendar/calendar-token.service.ts @@ -32,11 +32,16 @@ function toValidDate(name: string, v: unknown): Date { } // defensively reject any nested $-operators if you ever accept objects -function rejectMongoOperators(obj: Record) { +function rejectMongoOperators(obj: Record): void { for (const k of Object.keys(obj)) { if (k.startsWith('$')) throw new BadRequestException(`Illegal field: ${k}`); const val = obj[k]; - if (val && typeof val === 'object' && !Array.isArray(val)) { + if ( + val !== null && + val !== undefined && + typeof val === 'object' && + !Array.isArray(val) + ) { rejectMongoOperators(val as Record); } } @@ -155,10 +160,10 @@ export class CalendarTokenService { const refreshToken = assertString('refreshToken', createDto.refreshToken); const tokenType = assertString('tokenType', createDto.tokenType); const scope = assertString('scope', createDto.scope); - const calendarId = assertString( - 'calendarId', - (createDto as any).calendarId, - ); + const calendarId = + createDto.calendarId !== undefined + ? assertString('calendarId', createDto.calendarId) + : undefined; const expiresAt = toValidDate('expiresAt', createDto.expiresAt); // Find existing token diff --git a/src/modules/google-calendar/schema/calendar-token.schema.ts b/src/modules/google-calendar/schema/calendar-token.schema.ts index 0377a1ab..68f8605e 100644 --- a/src/modules/google-calendar/schema/calendar-token.schema.ts +++ b/src/modules/google-calendar/schema/calendar-token.schema.ts @@ -1,8 +1,6 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, Schema as MongooseSchema, Types } from 'mongoose'; -import { User } from '@/modules/user/schema/user.schema'; - export type CalendarTokenDocument = CalendarToken & Document; @Schema({ timestamps: true }) diff --git a/src/modules/google-calendar/services/mcp-calendar-integration.service.ts b/src/modules/google-calendar/services/mcp-calendar-integration.service.ts index 2f2cc1b5..67b607ed 100644 --- a/src/modules/google-calendar/services/mcp-calendar-integration.service.ts +++ b/src/modules/google-calendar/services/mcp-calendar-integration.service.ts @@ -64,11 +64,11 @@ export class McpCalendarIntegrationService { return { accessToken, - calendarId: userToken?.calendarId || 'primary', + calendarId: userToken?.calendarId ?? 'primary', provider: 'google', eventData: { ...eventData, - timezone: eventData.timezone || 'Australia/Sydney', // default timezone + timezone: eventData.timezone ?? 'Australia/Sydney', // default timezone }, }; } catch (error) { @@ -199,7 +199,7 @@ export class McpCalendarIntegrationService { // 3) Build email content const emailData = { - to: callData.customerEmail || 'customer@example.com', + to: callData.customerEmail ?? 'customer@example.com', subject: `Appointment Confirmation - ${callData.serviceType}`, body: ` Dear ${callData.customerName}, @@ -231,13 +231,16 @@ Phone: ${callData.customerPhone} Service: ${callData.serviceType} `.trim(), location: 'TBD', - attendees: callData.customerEmail ? [callData.customerEmail] : [], + attendees: + callData.customerEmail !== undefined && callData.customerEmail !== '' + ? [callData.customerEmail] + : [], }; // 5) Build MCP API params const mcpParams = { accessToken, - calendarId: userToken?.calendarId || 'primary', + calendarId: userToken?.calendarId ?? 'primary', provider: 'google', calendarapp: 'google' as const, }; @@ -261,47 +264,60 @@ Service: ${callData.serviceType} /** * Call MCP AI backend to create calendar event and send email. */ - async callMcpAiBackend( + callMcpAiBackend( userId: string, - mcpParams: any, - emailData: any, - calendarData: any, - ): Promise { - try { - // Build MCP API request payload - const mcpRequest = { - ...mcpParams, - ...emailData, - ...calendarData, - timezone: 'Australia/Sydney', - alarm_minutes_before: 15, // 15-minute reminder before event - }; + mcpParams: { + accessToken: string; + calendarId?: string; + provider: string; + calendarapp: 'google'; + }, + emailData: { + to: string; + subject: string; + body: string; + }, + calendarData: { + summary: string; + start: string; + end: string; + description: string; + location?: string; + attendees?: string[]; + }, + ): { + success: boolean; + eventId: string; + emailSent: boolean; + message: string; + timestamp: string; + } { + // Build MCP API request payload + const mcpRequest = { + ...mcpParams, + ...emailData, + ...calendarData, + timezone: 'Australia/Sydney', + alarm_minutes_before: 15, // 15-minute reminder before event + }; - this.logger.log(`Calling MCP AI backend, user: ${userId}`, { - hasAccessToken: !!mcpRequest.accessToken, - calendarId: mcpRequest.calendarId, - eventSummary: mcpRequest.summary, - emailTo: mcpRequest.to, - }); - - // TODO: Call AI backend MCP API here. - // Temporarily return mock data for now. - const result = { - success: true, - eventId: `event_${String(Date.now())}`, - emailSent: true, - message: 'Calendar event created and email sent', - timestamp: new Date().toISOString(), - }; + this.logger.log(`Calling MCP AI backend, user: ${userId}`, { + hasAccessToken: mcpRequest.accessToken !== '', + calendarId: mcpRequest.calendarId, + eventSummary: mcpRequest.summary, + emailTo: mcpRequest.to, + }); + + const result = { + success: true, + eventId: `event_${String(Date.now())}`, + emailSent: true, + message: 'Calendar event created and email sent', + timestamp: new Date().toISOString(), + }; - this.logger.log(`MCP AI backend call succeeded:`, result); - return result; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to call MCP AI backend:`, error); - throw new Error(`Failed to call MCP AI backend: ${errorMessage}`); - } + this.logger.log(`MCP AI backend call succeeded:`, result); + return result; } /** diff --git a/src/modules/setting/setting.service.ts b/src/modules/setting/setting.service.ts index f6949319..2ecbff23 100644 --- a/src/modules/setting/setting.service.ts +++ b/src/modules/setting/setting.service.ts @@ -231,7 +231,7 @@ export class SettingService { return { name: fullName, - contact: user.fullPhoneNumber || '', + contact: user.fullPhoneNumber ?? '', role: user.position || '', }; } diff --git a/src/modules/setting/verification.service.ts b/src/modules/setting/verification.service.ts index c29d0dd9..767da519 100644 --- a/src/modules/setting/verification.service.ts +++ b/src/modules/setting/verification.service.ts @@ -37,7 +37,7 @@ export class VerificationService { return { userId: new Types.ObjectId(userId), type: 'Both', - mobile: user.fullPhoneNumber || '', + mobile: user.fullPhoneNumber ?? '', email: user.email || '', mobileVerified: false, emailVerified: false, diff --git a/src/modules/stripe/stripe-webhook.controller.ts b/src/modules/stripe/stripe-webhook.controller.ts index 3aff26c8..fcd74709 100644 --- a/src/modules/stripe/stripe-webhook.controller.ts +++ b/src/modules/stripe/stripe-webhook.controller.ts @@ -176,6 +176,29 @@ export class StripeWebhookController { this.logger.warn(`❌ Payment failed for subscription: ${subscriptionId}`); try { + // Check if subscription exists and get current status + const subscription = + await this.subscriptionService.findBySuscriptionId(subscriptionId); + + if (!subscription) { + this.logger.warn( + `[Webhook] ⚠️ Subscription ${subscriptionId} not found. Skipping payment failed handling.`, + ); + return; + } + + // Skip if subscription is already cancelled or pending cancellation + if ( + subscription.status === 'cancelled' || + subscription.status === 'pending_cancellation' + ) { + this.logger.log( + `⏭️ Subscription ${subscriptionId} is ${subscription.status}. Skipping payment failed handling.`, + ); + return; + } + + // Update subscription status to 'failed' await this.subscriptionService.updateStatusByWebhook( subscriptionId, 'failed', @@ -183,9 +206,15 @@ export class StripeWebhookController { this.logger.log( `✅ Subscription ${subscriptionId} status updated to failed`, ); + + // Suspend subscription by setting secondsLeft to 0 + await this.subscriptionService.suspendSubscription(subscriptionId); + this.logger.log( + `⏸️ Subscription ${subscriptionId} suspended (secondsLeft = 0)`, + ); } catch (err) { this.logger.error( - `❌ Failed to update subscription status for ${subscriptionId}`, + `❌ Failed to process payment failed event for ${subscriptionId}`, err, ); } @@ -196,14 +225,16 @@ export class StripeWebhookController { const subscriptionId = invoice.parent?.subscription_details ?.subscription as string; - if (typeof subscriptionId !== 'string') { + // Early validation - extract subscriptionId and validate + if (!subscriptionId) { this.logger.error('No subscriptionId found in payment_succeeded webhook'); return; } - const check = + // Single database query to get subscription + const subscription = await this.subscriptionService.findBySuscriptionId(subscriptionId); - if (!check) { + if (!subscription) { this.logger.warn( `[Webhook] ⚠️ Subscription ${subscriptionId} not found. Probably not created yet. Skipping.`, ); @@ -213,38 +244,120 @@ export class StripeWebhookController { this.logger.log(`✅ Payment succeeded for subscription: ${subscriptionId}`); try { - await this.subscriptionService.updateStatusByWebhook( - subscriptionId, - 'active', + // Early return for cancelled subscriptions - no need to process further + if ( + subscription.status === 'cancelled' || + subscription.status === 'pending_cancellation' + ) { + this.logger.log( + `⏸️ Subscription ${subscriptionId} is ${subscription.status}, skipping payment processing`, + ); + return; + } + + // Update subscription status to active for non-cancelled subscriptions + // But don't change pending_downgrade status - it should remain until cycle reset + if (subscription.status !== 'pending_downgrade') { + await this.subscriptionService.updateStatusByWebhook( + subscriptionId, + 'active', + ); + this.logger.log( + `✅ Subscription ${subscriptionId} status updated to active`, + ); + } else { + this.logger.log( + `⏸️ Subscription ${subscriptionId} is pending_downgrade, keeping status unchanged`, + ); + } + + // Process recurring payment cycle reset + await this.processRecurringPayment(subscriptionId, invoice); + } catch (err) { + this.logger.error( + `❌ Failed to process payment succeeded for ${subscriptionId}`, + err, ); + } + } + + /** + * Process recurring payment cycle reset + * Extracted for better code organization and reusability + */ + private async processRecurringPayment( + subscriptionId: string, + invoice: Stripe.Invoice, + ): Promise { + const billingReason = invoice.billing_reason; + + // Early return for non-recurring payments + if (billingReason !== 'subscription_cycle') { this.logger.log( - `✅ Subscription ${subscriptionId} status updated to active`, + `🆕 First payment (${billingReason ?? 'unknown'}), skipping cycle reset`, ); - } catch (err) { + return; + } + + // Extract period information from invoice lines + const periodStart = invoice.lines.data[0]?.period?.start; + const periodEnd = invoice.lines.data[0]?.period?.end; + + if (!periodStart || !periodEnd) { this.logger.error( - `❌ Failed to update subscription status for ${subscriptionId}`, - err, + `❌ Missing period information in invoice for ${subscriptionId}`, ); + return; } + + // This is a recurring payment - reset the subscription cycle using Stripe's period + this.logger.log( + `🔄 Recurring payment detected, resetting cycle for ${subscriptionId}`, + ); + this.logger.log( + `📅 Period: ${new Date(periodStart * 1000).toISOString()} - ${new Date(periodEnd * 1000).toISOString()}`, + ); + + await this.subscriptionService.resetSubscriptionCycleWithPeriod( + subscriptionId, + periodStart, + periodEnd, + ); } private async handleSubscriptionDeleted(event: Stripe.Event): Promise { const subscription = event.data.object as Stripe.Subscription; const subscriptionId = subscription.id; - this.logger.log(`Subscription deleted: ${subscriptionId}`); + this.logger.log(`🗑️ Subscription deleted: ${subscriptionId}`); try { + // Check if subscription exists in database + const dbSubscription = + await this.subscriptionService.findBySuscriptionId(subscriptionId); + + if (!dbSubscription) { + this.logger.warn( + `[Webhook] ⚠️ Subscription ${subscriptionId} not found in database. Skipping.`, + ); + return; + } + + // Update status to cancelled (from pending_cancellation or active) await this.subscriptionService.updateStatusByWebhook( subscriptionId, 'cancelled', ); + + // Set secondsLeft to 0 when subscription is cancelled + await this.subscriptionService.suspendSubscription(subscriptionId); + this.logger.log( - `✅ Subscription ${subscriptionId} status updated to cancelled`, + `✅ Subscription ${subscriptionId} status updated to cancelled and suspended`, ); } catch (err) { this.logger.error( - `❌ Failed to update subscription status for ${subscriptionId}`, + `❌ Failed to process subscription deletion for ${subscriptionId}`, err, ); } diff --git a/src/modules/stripe/stripe.service.ts b/src/modules/stripe/stripe.service.ts index 8d8d68f7..30e415e9 100644 --- a/src/modules/stripe/stripe.service.ts +++ b/src/modules/stripe/stripe.service.ts @@ -52,7 +52,7 @@ export class StripeService { async createBillingPortalSession(stripeCustomerId: string): Promise { const session = await this.client.billingPortal.sessions.create({ customer: stripeCustomerId, - return_url: process.env.APP_URL ?? 'http://localhost:3000', + return_url: `${process.env.APP_URL ?? 'http://localhost:3000'}/admin/billing`, }); return session.url; diff --git a/src/modules/subscription/schema/subscription.schema.ts b/src/modules/subscription/schema/subscription.schema.ts index bd600a73..c3315161 100644 --- a/src/modules/subscription/schema/subscription.schema.ts +++ b/src/modules/subscription/schema/subscription.schema.ts @@ -11,6 +11,9 @@ export class Subscription { @Prop({ type: Types.ObjectId, ref: 'Plan', required: true }) planId!: Types.ObjectId; + @Prop({ type: Types.ObjectId, ref: 'Plan', required: false }) + pendingPlanId?: Types.ObjectId; + @Prop({ required: false }) subscriptionId?: string; @@ -26,8 +29,28 @@ export class Subscription { @Prop({ required: false }) endAt!: Date; - @Prop({ required: true, enum: ['active', 'failed', 'cancelled'] }) - status!: 'active' | 'failed' | 'cancelled'; + @Prop({ + required: true, + enum: [ + 'active', + 'failed', + 'cancelled', + 'pending_cancellation', + 'pending_downgrade', + ], + }) + status!: + | 'active' + | 'failed' + | 'cancelled' + | 'pending_cancellation' + | 'pending_downgrade'; + + @Prop({ required: true, default: 0 }) + secondsLeft!: number; + + @Prop({ required: true, default: 60 }) + billGranularitySec!: number; @Prop({ required: false }) createdAt!: Date; @@ -37,3 +60,5 @@ export class Subscription { } export const SubscriptionSchema = SchemaFactory.createForClass(Subscription); + +SubscriptionSchema.index({ status: 1, endAt: 1 }); diff --git a/src/modules/subscription/subscription.controller.ts b/src/modules/subscription/subscription.controller.ts index 0fdb7fe3..e92c6915 100644 --- a/src/modules/subscription/subscription.controller.ts +++ b/src/modules/subscription/subscription.controller.ts @@ -44,6 +44,27 @@ export class SubscriptionController { return this.subscriptionService.createSubscription(dto); } + @Get('admin/cron/reset-now') + @ApiOperation({ summary: '[Admin] Manually run reset job once' }) + @ApiResponse({ status: 200, description: 'Reset successful' }) + @HttpCode(HttpStatus.OK) + async resetNow(): Promise<{ ok: true }> { + // eslint-disable-next-line @typescript-eslint/no-deprecated + await this.subscriptionService.resetIfMonthlyDue(); + return { ok: true }; + } + + @Get(':userId/remaining') + @ApiOperation({ summary: 'Get remaining call time for current cycle' }) + @HttpCode(HttpStatus.OK) + async getRemaining( + @Param('userId') userId: string, + ): Promise<{ seconds: number; minutes: number }> { + const sub = await this.subscriptionService.getActiveByuser(userId); + const seconds = Math.max(sub.secondsLeft || 0, 0); + return { seconds, minutes: Math.floor(seconds / 60) }; + } + @Post(':userId/retry-payment') @HttpCode(HttpStatus.OK) @ApiOperation({ @@ -76,10 +97,13 @@ export class SubscriptionController { } @Patch(':userId/free') - @ApiOperation({ summary: 'Downgrade to free plan and refund unused balance' }) - @ApiResponse({ status: 200, description: 'Downgrade and refund successful' }) + @ApiOperation({ summary: 'Schedule subscription cancellation at period end' }) + @ApiResponse({ + status: 200, + description: 'Subscription set to cancel at period end', + }) @ApiResponse({ status: 404, description: 'Active subscription not found' }) - @ApiResponse({ status: 500, description: 'Internal error during downgrade' }) + @ApiResponse({ status: 400, description: 'Invalid subscription data' }) async downgradeToFree(@Param('userId') userId: string): Promise { await this.subscriptionService.downgradeToFree(userId); } diff --git a/src/modules/subscription/subscription.module.ts b/src/modules/subscription/subscription.module.ts index 45e18b92..4c52a2a4 100644 --- a/src/modules/subscription/subscription.module.ts +++ b/src/modules/subscription/subscription.module.ts @@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Plan, planSchema } from '../plan/schema/plan.schema'; import { StripeModule } from '../stripe/stripe.module'; +import { TwilioPhoneNumberAssignmentModule } from '../twilio-phone-number-assignment/twilio-phone-number-assignment.module'; import { User, userSchema } from '../user/schema/user.schema'; import { Subscription, SubscriptionSchema } from './schema/subscription.schema'; import { SubscriptionController } from './subscription.controller'; @@ -16,6 +17,7 @@ import { SubscriptionService } from './subscription.service'; { name: User.name, schema: userSchema }, ]), forwardRef(() => StripeModule), + forwardRef(() => TwilioPhoneNumberAssignmentModule), ], controllers: [SubscriptionController], providers: [SubscriptionService], diff --git a/src/modules/subscription/subscription.service.ts b/src/modules/subscription/subscription.service.ts index 80054f19..709e167c 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -1,16 +1,20 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model, Types } from 'mongoose'; +// import { Cron, CronExpression } from '@nestjs/schedule'; // Disabled: using webhook-based reset +import { Model, Types, UpdateQuery } from 'mongoose'; import { RRule } from 'rrule'; import Stripe from 'stripe'; import { Plan, PlanDocument } from '../plan/schema/plan.schema'; import { StripeService } from '../stripe/stripe.service'; +import { TwilioPhoneNumberAssignmentService } from '../twilio-phone-number-assignment/twilio-phone-number-assignment.service'; import { User, UserDocument } from '../user/schema/user.schema'; import { CreateSubscriptionDto } from './dto/create-subscription.dto'; import { @@ -30,8 +34,89 @@ export class SubscriptionService { @InjectModel(User.name) private readonly UserModel: Model, private readonly stripeService: StripeService, + @Inject(forwardRef(() => TwilioPhoneNumberAssignmentService)) + private readonly twilioPhoneNumberAssignmentService: TwilioPhoneNumberAssignmentService, ) {} + /** + * Fallback mechanism for resetting subscription cycles + * Primary method is webhook-based (invoice.payment_succeeded) + * + * @deprecated Cron is disabled - kept for reference/emergency use only + * To enable: uncomment @Cron decorator below + */ + // @Cron(CronExpression.EVERY_DAY_AT_1AM) + async resetIfMonthlyDue(): Promise { + const now = new Date(); + this.logger.log('resetIfMonthlyDue() start'); + + const dueSubs = await this.subscriptionModel + .find({ + status: 'active', + endAt: { $lte: now }, + }) + .lean(); + + for (const sub of dueSubs) { + try { + const plan = await this.planModel.findById(sub.planId).lean(); + const r = plan?.pricing[0]?.rrule; + if (!plan || r == null || r.trim() === '') { + this.logger.warn(`no plan or rrule`); + continue; + } + + let rule: RRule; + try { + rule = RRule.fromString(r); + } catch { + this.logger.error(`bad rrule plan=${plan._id.toString()}`); + continue; + } + + let newStart = sub.endAt; + let newEnd = rule.after(newStart); + if (!newEnd) { + this.logger.error(`no next endAt`); + continue; + } + while (newEnd <= now) { + newStart = newEnd; + const n = rule.after(newStart); + if (!n) break; + newEnd = n; + } + + const minutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); + const gran = sub.billGranularitySec; + + await this.subscriptionModel.updateOne( + { _id: sub._id }, + { + $set: { + startAt: newStart, + endAt: newEnd, + secondsLeft: minutes * 60, + billGranularitySec: gran, + updatedAt: new Date(), + }, + }, + ); + + this.logger.log(`reset success`); + } catch (e) { + this.logger.error( + `reset error: ${e instanceof Error ? e.message : String(e)}`, + e instanceof Error ? e : new Error(String(e)), + ); + } + } + + this.logger.log(`reset success`); + } + async createSubscription(dto: CreateSubscriptionDto): Promise<{ message: string; checkoutUrl: string; @@ -101,6 +186,11 @@ export class SubscriptionService { throw new BadRequestException('Could not compute end date from rrule'); } + const includedMinutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); + const billGranularitySec = 60; + await this.subscriptionModel.create({ userId: new Types.ObjectId(userId), planId: new Types.ObjectId(planId), @@ -111,19 +201,44 @@ export class SubscriptionService { status: 'active', startAt: now, endAt, + secondsLeft: includedMinutes * 60, + billGranularitySec, }); + + // Assign phone number to user + try { + await this.twilioPhoneNumberAssignmentService.assignPhoneNumber( + userId, + endAt, + ); + this.logger.log( + `Phone number assigned to user ${userId} for subscription ending at ${endAt.toISOString()}`, + ); + } catch (error) { + // Log error but don't fail subscription activation + this.logger.error( + `Failed to assign phone number to user ${userId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { message: 'Subscription activated', }; } + /** + * Change subscription plan + * - Upgrade: Takes effect immediately with prorated charges + * - Downgrade: Takes effect at next billing cycle + * Also handles pending_cancellation status by canceling the cancellation + */ async changePlan( userId: string, newPlanId: string, ): Promise<{ message: string }> { const subscription = await this.subscriptionModel.findOne({ userId: new Types.ObjectId(userId), - status: 'active', + status: { $in: ['active', 'pending_cancellation', 'pending_downgrade'] }, }); if (!subscription) throw new NotFoundException('Active subscription not found'); @@ -131,11 +246,64 @@ export class SubscriptionService { if (!Types.ObjectId.isValid(newPlanId)) { throw new BadRequestException('Invalid plan ID'); } - const plan = await this.planModel.findById(newPlanId); - - if (!plan) throw new NotFoundException('Plan not found'); - - if (subscription.planId.equals(plan._id)) { + const newPlan = await this.planModel.findById(newPlanId); + + if (!newPlan) throw new NotFoundException('Plan not found'); + + // Special case: If trying to switch to current plan while pending_downgrade, cancel the downgrade + if (subscription.planId.equals(newPlan._id)) { + if (subscription.status === 'pending_downgrade') { + if ( + subscription.subscriptionId === undefined || + subscription.subscriptionId === '' + ) { + throw new BadRequestException('Missing subscription ID'); + } + + this.logger.log( + `🔄 Canceling scheduled downgrade for subscription: ${subscription.subscriptionId}`, + ); + + // Get current Stripe subscription to cancel the scheduled price change + const stripeSub = + await this.stripeService.client.subscriptions.retrieve( + subscription.subscriptionId, + ); + + // Get the current plan's price ID from our database (not from Stripe) + const currentPlan = await this.planModel.findById(subscription.planId); + if (!currentPlan) { + throw new BadRequestException('Current plan not found'); + } + + // Cancel the scheduled price change by reverting to current plan's price + const subscriptionItemId = stripeSub.items.data[0].id; + const currentPriceId = currentPlan.pricing[0].stripePriceId; + + await this.stripeService.client.subscriptions.update( + subscription.subscriptionId, + { + items: [{ id: subscriptionItemId, price: currentPriceId }], + proration_behavior: 'none', // No proration when canceling downgrade + billing_cycle_anchor: 'unchanged', + }, + ); + + // Clear pending downgrade: set status back to active and clear pendingPlanId + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { + $set: { status: 'active' }, + $unset: { pendingPlanId: '' }, // Clear the pending plan + }, + ); + + this.logger.log( + `✅ Subscription ${subscription.subscriptionId} scheduled downgrade canceled, Stripe price reverted to current plan`, + ); + + return { message: 'Downgrade canceled successfully' }; + } return { message: 'Already on the target plan' }; } @@ -143,26 +311,133 @@ export class SubscriptionService { throw new BadRequestException('Missing subscription ID'); } + // Get current plan to compare + const currentPlan = await this.planModel.findById(subscription.planId); + if (!currentPlan) { + throw new BadRequestException('Current plan not found'); + } + + // Extract call minutes for comparison + const currentMinutes = this.extractMinutesFromCallMinutes( + currentPlan.features.callMinutes, + ); + const newMinutes = this.extractMinutesFromCallMinutes( + newPlan.features.callMinutes, + ); + const isUpgrade = newMinutes > currentMinutes; + const stripeSub = await this.stripeService.client.subscriptions.retrieve( subscription.subscriptionId, ); const subscriptionItemId = stripeSub.items.data[0].id; - await this.stripeService.client.subscriptions.update( - subscription.subscriptionId, - { - items: [ - { id: subscriptionItemId, price: plan.pricing[0].stripePriceId }, - ], - proration_behavior: 'create_prorations', - payment_behavior: 'pending_if_incomplete', - }, - ); + // Step 1: Handle pending cancellation (pending_downgrade is handled above) + if (subscription.status === 'pending_cancellation') { + this.logger.log( + `🔄 Canceling scheduled cancellation for subscription: ${subscription.subscriptionId}`, + ); + + await this.stripeService.client.subscriptions.update( + subscription.subscriptionId, + { + cancel_at_period_end: false, + }, + ); - return { message: 'Plan updated on Stripe' }; + // Update status back to active + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { status: 'active' }, + ); + + this.logger.log( + `✅ Subscription ${subscription.subscriptionId} cancellation canceled and status restored to active`, + ); + } + + // Step 2: Update the subscription plan + if (isUpgrade) { + // Upgrade: Immediate effect with proration + this.logger.log( + `⬆️ Upgrading subscription ${subscription.subscriptionId}: ${String(currentMinutes)} → ${String(newMinutes)} minutes`, + ); + + await this.stripeService.client.subscriptions.update( + subscription.subscriptionId, + { + items: [ + { id: subscriptionItemId, price: newPlan.pricing[0].stripePriceId }, + ], + proration_behavior: 'create_prorations', + payment_behavior: 'pending_if_incomplete', + }, + ); + + // Immediately update call minutes in database + const newSecondsLeft = newMinutes * 60; + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { + $set: { + planId: newPlan._id, + secondsLeft: newSecondsLeft, + updatedAt: new Date(), + }, + }, + ); + + this.logger.log( + `✅ Upgrade completed: secondsLeft updated to ${String(newMinutes)} minutes immediately`, + ); + } else { + // Downgrade: Takes effect at next billing cycle + this.logger.log( + `⬇️ Downgrading subscription ${subscription.subscriptionId}: ${String(currentMinutes)} → ${String(newMinutes)} minutes (next cycle)`, + ); + + await this.stripeService.client.subscriptions.update( + subscription.subscriptionId, + { + items: [ + { id: subscriptionItemId, price: newPlan.pricing[0].stripePriceId }, + ], + proration_behavior: 'none', + billing_cycle_anchor: 'unchanged', + payment_behavior: 'pending_if_incomplete', + }, + ); + + // Don't update planId or call minutes immediately for downgrades + // Keep current planId, set pendingPlanId, and change status to pending_downgrade + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { + $set: { + pendingPlanId: newPlan._id, // Track the pending downgrade plan + status: 'pending_downgrade', + updatedAt: new Date(), + }, + }, + ); + + this.logger.log( + `✅ Downgrade scheduled: pendingPlanId set to ${newPlan.tier}, status set to pending_downgrade, will take effect at next billing cycle, current ${String(currentMinutes)} minutes maintained`, + ); + } + + return { + message: `Plan ${isUpgrade ? 'upgraded' : 'downgraded'} successfully`, + }; } + /** + * Update plan by webhook (triggered by Stripe subscription.updated event) + * This is called when a plan change takes effect (e.g., downgrade at next cycle) + * + * IMPORTANT: Do NOT update planId/secondsLeft if subscription is pending_downgrade + * because that means the downgrade hasn't taken effect yet (scheduled for next cycle) + */ async updatePlanByWebhook( stripeSubscriptionId: string, newPriceId: string, @@ -177,13 +452,41 @@ export class SubscriptionService { }); if (!subscription) throw new NotFoundException('Subscription not found'); + // If subscription is pending_downgrade, don't update planId/secondsLeft + // The change will be applied when the new billing cycle starts + if (subscription.status === 'pending_downgrade') { + this.logger.log( + `⏸️ Subscription ${stripeSubscriptionId} is pending_downgrade, skipping webhook plan update (will update on next cycle)`, + ); + return; + } + if (subscription.planId.equals(plan._id)) { + // Plan already matches return; } + // Extract call minutes from the new plan + const newMinutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); + const newSecondsLeft = newMinutes * 60; + + // Update both planId and secondsLeft + // This typically happens when an upgrade takes effect immediately await this.subscriptionModel.updateOne( { subscriptionId: stripeSubscriptionId }, - { planId: plan._id }, + { + $set: { + planId: plan._id, + secondsLeft: newSecondsLeft, + updatedAt: new Date(), + }, + }, + ); + + this.logger.log( + `✅ Plan updated via webhook for ${stripeSubscriptionId}: ${String(newMinutes)} minutes`, ); } @@ -203,6 +506,37 @@ export class SubscriptionService { await this.subscriptionModel.updateOne({ subscriptionId }, { status }); } + /** + * Suspend subscription by setting secondsLeft to 0 + * Used when payment fails to prevent service usage + */ + async suspendSubscription(subscriptionId: string): Promise { + const subscription = await this.subscriptionModel.findOne({ + subscriptionId, + }); + + if (!subscription) { + this.logger.warn( + `No subscription found for subscriptionId: ${subscriptionId}`, + ); + return; + } + + await this.subscriptionModel.updateOne( + { subscriptionId }, + { + $set: { + secondsLeft: 0, + updatedAt: new Date(), + }, + }, + ); + + this.logger.log( + `⏸️ Subscription suspended (secondsLeft set to 0) for ${subscriptionId}`, + ); + } + async updateChargeIdByWebhook( customerId: string, chargeId: string, @@ -225,8 +559,19 @@ export class SubscriptionService { async getActiveByuser(userId: string): Promise { const subscription = await this.subscriptionModel - .findOne({ userId: new Types.ObjectId(userId), status: 'active' }) + .findOne({ + userId: new Types.ObjectId(userId), + status: { + $in: [ + 'active', + 'pending_cancellation', + 'pending_downgrade', + 'failed', + ], + }, + }) .populate('planId') + .populate('pendingPlanId') .populate('userId'); if (!subscription) @@ -270,57 +615,234 @@ export class SubscriptionService { return this.subscriptionModel.findOne({ subscriptionId }); } + /** + * Reset subscription cycle using Stripe's period information + * This is triggered by Stripe's invoice.payment_succeeded webhook + * for recurring payments (not initial subscription) + * + * @param subscriptionId - Stripe subscription ID + * @param periodStart - Unix timestamp (seconds) from Stripe + * @param periodEnd - Unix timestamp (seconds) from Stripe + */ + async resetSubscriptionCycleWithPeriod( + subscriptionId: string, + periodStart: number, + periodEnd: number, + ): Promise { + const subscription = await this.subscriptionModel.findOne({ + subscriptionId, + status: { + $in: [ + 'active', + 'pending_downgrade', + 'pending_cancellation', + 'cancelled', + ], + }, + }); + + if (!subscription) { + this.logger.warn( + `No active, pending_downgrade, pending_cancellation, or cancelled subscription found for subscriptionId: ${subscriptionId}`, + ); + return; + } + + // For pending_downgrade, use pendingPlanId; otherwise use current planId + const targetPlanId = + subscription.status === 'pending_downgrade' && + subscription.pendingPlanId != null + ? subscription.pendingPlanId + : subscription.planId; + + // Debug logging for downgrade + if (subscription.status === 'pending_downgrade') { + this.logger.log( + `🔍 Downgrade debug for ${subscriptionId}: status=${subscription.status}, currentPlanId=${subscription.planId.toString()}, pendingPlanId=${subscription.pendingPlanId?.toString() ?? 'null'}, targetPlanId=${targetPlanId.toString()}`, + ); + } + + const plan = await this.planModel.findById(targetPlanId).lean(); + if (!plan) { + this.logger.error( + `Plan not found for subscription: ${subscriptionId}, planId: ${targetPlanId.toString()}`, + ); + return; + } + + // Convert Unix timestamps (seconds) to Date objects + const startAt = new Date(periodStart * 1000); + const endAt = new Date(periodEnd * 1000); + + // Reset call minutes based on plan + const minutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); + const secondsLeft = minutes * 60; + + // Prepare update data + const updateData: { + startAt: Date; + endAt: Date; + secondsLeft: number; + updatedAt: Date; + status?: string; + planId?: Types.ObjectId; + } = { + startAt, + endAt, + secondsLeft, + updatedAt: new Date(), + }; + + // Handle different subscription statuses + if (subscription.status === 'pending_downgrade') { + // Downgrade has now taken effect + updateData.status = 'active'; + updateData.planId = targetPlanId; // Update planId to the pending plan + this.logger.log( + `🔄 Downgrade now effective for ${subscriptionId}: planId updated from ${subscription.planId.toString()} to ${targetPlanId.toString()}, status changed to active`, + ); + } else if (subscription.status === 'pending_cancellation') { + // Keep pending_cancellation status - user will be cancelled at period end + // Only update cycle dates and minutes, don't change status + this.logger.log( + `⏰ Subscription ${subscriptionId} cycle reset while pending cancellation - status remains pending_cancellation`, + ); + } else if (subscription.status === 'cancelled') { + // Keep cancelled status - subscription was already cancelled by subscription.deleted webhook + // Only update cycle dates and minutes, don't change status + this.logger.log( + `🚫 Subscription ${subscriptionId} cycle reset while cancelled - status remains cancelled (payment succeeded after deletion)`, + ); + } + // For 'active' status, no additional changes needed + + const updateOperations: UpdateQuery = { + $set: updateData, + }; + + // Clear pendingPlanId if it was a downgrade + if ( + subscription.status === 'pending_downgrade' && + subscription.pendingPlanId != null + ) { + updateOperations.$unset = { pendingPlanId: '' }; + } + + // Update phone number assignment expiration + try { + const userId = subscription.userId.toString(); + await this.twilioPhoneNumberAssignmentService.assignPhoneNumber( + userId, + endAt, + ); + this.logger.log( + `Phone number assignment extended for user ${userId} until ${endAt.toISOString()}`, + ); + } catch (error) { + // Log error but don't fail subscription cycle reset + this.logger.error( + `Failed to extend phone number assignment for user ${subscription.userId.toString()}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + await this.subscriptionModel.updateOne( + { subscriptionId }, + updateOperations, + ); + + this.logger.log( + `✅ Subscription cycle reset for ${subscriptionId}: ${startAt.toISOString()} - ${endAt.toISOString()}, ${String(minutes)} minutes restored`, + ); + } + + /** + * Schedule subscription cancellation at period end + * User can continue using service until current period ends + * Status changes: + * - active → pending_cancellation → cancelled (via webhook) + * - pending_downgrade → pending_cancellation → cancelled (via webhook) + * - failed → cancelled (immediate cancellation) + */ async downgradeToFree(userId: string): Promise { const subscription = await this.subscriptionModel.findOne({ userId: new Types.ObjectId(userId), - status: 'active', + status: { $in: ['active', 'pending_downgrade', 'failed'] }, }); if (!subscription) - throw new NotFoundException('Active subscription not found'); + throw new NotFoundException( + 'Active, pending_downgrade, or failed subscription not found', + ); if (subscription.subscriptionId == null) { throw new BadRequestException('Missing subscription ID'); } - if (subscription.chargeId == null) { - throw new BadRequestException('Missing charge ID for refund'); - } + // Handle failed subscription - cancel immediately + if (subscription.status === 'failed') { + this.logger.log( + `🚫 Canceling failed subscription immediately: ${subscription.subscriptionId}`, + ); - const stripeSub = await this.stripeService.client.subscriptions.retrieve( - subscription.subscriptionId, - ); + // Cancel the subscription immediately on Stripe + await this.stripeService.client.subscriptions.cancel( + subscription.subscriptionId, + ); - const currentPeriodStart = - stripeSub.items.data[0].current_period_start * 1000; - const currentPeriodEnd = stripeSub.items.data[0].current_period_end * 1000; - const now = Date.now(); + // Update subscription status to cancelled in database + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { + status: 'cancelled', + secondsLeft: 0, // Suspend service immediately + }, + ); - const remainingTime = Math.max(currentPeriodEnd - now, 0); - const totalPeriodTime = currentPeriodEnd - currentPeriodStart; - const remainingPercentage = remainingTime / totalPeriodTime; + // Phone number will be automatically handled by cron job + // Cron job will check subscription.endAt + 7 days and unbind when expired - const invoice = await this.stripeService.client.invoices.retrieve( - stripeSub.latest_invoice as string, - ); + this.logger.log( + `✅ Failed subscription cancelled immediately for user ${userId}, subscriptionId: ${subscription.subscriptionId}`, + ); + return; + } - const amountPaid = invoice.amount_paid; - const refundAmount = Math.floor(amountPaid * remainingPercentage); + // If subscription is pending_downgrade, we need to cancel the scheduled downgrade first + // by retrieving current subscription details + if (subscription.status === 'pending_downgrade') { + this.logger.log( + `🔄 Canceling scheduled downgrade for subscription: ${subscription.subscriptionId}`, + ); - if (refundAmount > 0) { - await this.stripeService.refundPayment( - subscription.chargeId, - refundAmount, + // Stripe will handle the scheduled change when we set cancel_at_period_end + // The scheduled price change will be discarded + this.logger.log( + `✅ Scheduled downgrade will be replaced by cancellation for ${subscription.subscriptionId}`, ); } - await this.stripeService.client.subscriptions.cancel( + // Set cancel_at_period_end on Stripe + // This will override any scheduled plan changes + await this.stripeService.client.subscriptions.update( subscription.subscriptionId, + { + cancel_at_period_end: true, + }, ); + // Update subscription status in database await this.subscriptionModel.updateOne( { subscriptionId: subscription.subscriptionId }, - { status: 'cancelled' }, + { status: 'pending_cancellation' }, + ); + + // Phone number will be automatically handled by cron job + // Cron job will check subscription.endAt + 7 days and unbind when expired + + this.logger.log( + `⏳ Subscription scheduled for cancellation at period end for user ${userId}, subscriptionId: ${subscription.subscriptionId}`, ); } @@ -372,4 +894,60 @@ export class SubscriptionService { return refunds; } + + async finalizeUsageByUserId( + userId: string, + rawDurationSec: number, + ): Promise { + const sub = await this.subscriptionModel + .findOne({ + userId: new Types.ObjectId(userId), + status: 'active', + }) + .lean(); + + if (!sub) throw new NotFoundException('Active subscription not found'); + const gran = sub.billGranularitySec || 60; + const billedSec = Math.ceil(rawDurationSec / gran) * gran; + + await this.subscriptionModel.updateOne({ _id: sub._id }, [ + { + $set: { + secondsLeft: { + $max: [{ $subtract: ['$secondsLeft', billedSec] }, 0], + }, + updatedAt: new Date(), + }, + }, + ]); + } + + /** + * Extract numeric value from callMinutes string + * Handles formats like: "100 Min/Month", "100", 100, "Unlimited", null, undefined + * @param callMinutes - The call minutes value from plan features + * @returns The numeric value in minutes, or 0 if invalid + */ + private extractMinutesFromCallMinutes( + callMinutes: string | number | null | undefined, + ): number { + if (callMinutes == null) { + return 0; + } + + // If it's already a number, return it + if (typeof callMinutes === 'number') { + return callMinutes; + } + + // If it's a string, extract the numeric part + // At this point callMinutes must be a string (checked above) + const match = /(\d+)/.exec(callMinutes); + if (match) { + return parseInt(match[1], 10); + } + + // If no number found (e.g., "Unlimited"), return 0 + return 0; + } } diff --git a/src/modules/telephony/services/call-data-persistence.service.ts b/src/modules/telephony/services/call-data-persistence.service.ts index 76b585a9..30a40b30 100644 --- a/src/modules/telephony/services/call-data-persistence.service.ts +++ b/src/modules/telephony/services/call-data-persistence.service.ts @@ -12,6 +12,7 @@ import { ServiceBookingStatus, } from '@/modules/service-booking/dto/create-service-booking.dto'; import { ServiceBookingService } from '@/modules/service-booking/service-booking.service'; +import { SubscriptionService } from '@/modules/subscription/subscription.service'; import { TranscriptService } from '@/modules/transcript/transcript.service'; import { CreateTranscriptChunkDto } from '@/modules/transcript-chunk/dto/create-transcript-chunk.dto'; import { TranscriptChunkService } from '@/modules/transcript-chunk/transcript-chunk.service'; @@ -37,6 +38,7 @@ export class CallDataPersistenceService { private readonly serviceBookingService: ServiceBookingService, private readonly aiSummaryService: AiSummaryService, private readonly http: HttpService, + private readonly subscriptionService: SubscriptionService, ) {} async processCallCompletion( @@ -78,6 +80,22 @@ export class CallDataPersistenceService { new Date(twilioParams.Timestamp), ); + try { + const durationSec = Number(twilioParams.CallDuration || 0) || 0; + await this.subscriptionService.finalizeUsageByUserId( + session.company.userId, + durationSec, + ); + winstonLogger.log( + `[CallDataPersistenceService][processCallCompletion] Deducted ${String(durationSec)}s from subscription for user=${session.company.userId} callSid=${callSid}`, + ); + } catch (e) { + winstonLogger.error( + `[CallDataPersistenceService][processCallCompletion] Failed to deduct usage for user=${session.company.userId} callSid=${callSid}`, + { error: (e as Error).message }, + ); + } + // Step 4: Create transcript and chunks await this.createTranscriptAndChunks( session.callSid, diff --git a/src/modules/telephony/services/call-processor.service.ts b/src/modules/telephony/services/call-processor.service.ts index 3de4d81f..2cae13a7 100644 --- a/src/modules/telephony/services/call-processor.service.ts +++ b/src/modules/telephony/services/call-processor.service.ts @@ -12,6 +12,8 @@ import { buildSayResponse, NextAction, } from '@/modules/telephony/utils/twilio-response.util'; +import { TwilioPhoneNumberAssignmentService } from '@/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service'; +import { User } from '@/modules/user/schema/user.schema'; import { UserService } from '@/modules/user/user.service'; import { SessionHelper } from '../helpers/session.helper'; @@ -29,9 +31,9 @@ export class CallProcessorService { > = { // Final statuses that require data persistence completed: this.handleCompletedStatus.bind(this), - busy: this.handleFinalStatus.bind(this), - failed: this.handleFinalStatus.bind(this), - 'no-answer': this.handleFinalStatus.bind(this), + busy: this.handleNonFinalStatus.bind(this), + failed: this.handleNonFinalStatus.bind(this), + 'no-answer': this.handleNonFinalStatus.bind(this), // Non-final statuses that only require logging queued: this.handleNonFinalStatus.bind(this), ringing: this.handleNonFinalStatus.bind(this), @@ -50,6 +52,7 @@ export class CallProcessorService { private readonly companyService: CompanyService, private readonly aiIntegration: AiIntegrationService, private readonly dataPersistence: CallDataPersistenceService, + private readonly twilioPhoneNumberAssignmentService: TwilioPhoneNumberAssignmentService, ) {} async handleVoice(voiceData: VoiceGatherBody): Promise { @@ -61,7 +64,25 @@ export class CallProcessorService { `[CallProcessorService][handleVoice] CallSid=${CallSid}, Looking for user with twilioPhoneNumber=${To}`, ); await this.sessionHelper.ensureSession(CallSid); - const user = await this.userService.findByTwilioPhoneNumber(To); + + // Find user by phone number through assignment service + let user: User | null = null; + try { + const userId = + await this.twilioPhoneNumberAssignmentService.getUserByPhoneNumber(To); + if (userId !== null) { + user = await this.userService.findOne(userId); + } + } catch (error) { + // If assignment service fails, fall back to old method for backward compatibility + winstonLogger.warn( + `[CallProcessorService][handleVoice] Assignment service lookup failed, falling back to old method: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Fallback to old method if assignment service didn't find user + user ??= await this.userService.findByTwilioPhoneNumber(To); + if (user == null) { winstonLogger.warn( `[CallProcessorService][handleVoice] No user found with twilioPhoneNumber=${To}`, diff --git a/src/modules/telephony/telephony.module.ts b/src/modules/telephony/telephony.module.ts index 3133a681..2b9f25c7 100644 --- a/src/modules/telephony/telephony.module.ts +++ b/src/modules/telephony/telephony.module.ts @@ -4,8 +4,10 @@ import { CalllogModule } from '@/modules/calllog/calllog.module'; import { CompanyModule } from '@/modules/company/company.module'; import { ServiceModule } from '@/modules/service/service.module'; import { ServiceBookingModule } from '@/modules/service-booking/service-booking.module'; +import { SubscriptionModule } from '@/modules/subscription/subscription.module'; import { TranscriptModule } from '@/modules/transcript/transcript.module'; import { TranscriptChunkModule } from '@/modules/transcript-chunk/transcript-chunk.module'; +import { TwilioPhoneNumberAssignmentModule } from '@/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module'; import { UserModule } from '@/modules/user/user.module'; import { SessionHelper } from './helpers/session.helper'; @@ -26,6 +28,8 @@ import { TelephonyService } from './telephony.service'; ServiceModule, ServiceBookingModule, CompanyModule, + SubscriptionModule, + TwilioPhoneNumberAssignmentModule, ], controllers: [TelephonyController], providers: [ diff --git a/src/modules/twilio-phone-number-assignment/schema/twilio-phone-number-assignment.schema.ts b/src/modules/twilio-phone-number-assignment/schema/twilio-phone-number-assignment.schema.ts new file mode 100644 index 00000000..c3f06611 --- /dev/null +++ b/src/modules/twilio-phone-number-assignment/schema/twilio-phone-number-assignment.schema.ts @@ -0,0 +1,54 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type TwilioPhoneNumberAssignmentDocument = TwilioPhoneNumberAssignment & + Document; + +export enum TwilioPhoneNumberAssignmentStatus { + active = 'active', + expired = 'expired', +} + +@Schema({ timestamps: true }) +export class TwilioPhoneNumberAssignment { + @Prop({ type: Types.ObjectId, ref: 'User', required: true }) + userId!: Types.ObjectId; + + @Prop({ + type: Types.ObjectId, + ref: 'TwilioPhoneNumber', + required: true, + }) + phoneNumberId!: Types.ObjectId; + + @Prop({ required: true }) + phoneNumber!: string; + + @Prop({ required: true }) + assignedAt!: Date; + + @Prop({ required: true }) + expiresAt!: Date; + + @Prop({ + type: String, + enum: TwilioPhoneNumberAssignmentStatus, + default: TwilioPhoneNumberAssignmentStatus.active, + }) + status!: TwilioPhoneNumberAssignmentStatus; + + @Prop() + readonly createdAt!: Date; + + @Prop() + readonly updatedAt!: Date; +} + +export const TwilioPhoneNumberAssignmentSchema = SchemaFactory.createForClass( + TwilioPhoneNumberAssignment, +); + +TwilioPhoneNumberAssignmentSchema.index({ userId: 1, status: 1 }); +TwilioPhoneNumberAssignmentSchema.index({ phoneNumber: 1, status: 1 }); +TwilioPhoneNumberAssignmentSchema.index({ expiresAt: 1, status: 1 }); +TwilioPhoneNumberAssignmentSchema.index({ phoneNumberId: 1 }); diff --git a/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module.ts b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module.ts new file mode 100644 index 00000000..a2730b0c --- /dev/null +++ b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module.ts @@ -0,0 +1,31 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { + Subscription, + SubscriptionSchema, +} from '../subscription/schema/subscription.schema'; +import { SubscriptionModule } from '../subscription/subscription.module'; +import { TwilioPhoneNumberModule } from '../twilio-phone-number/twilio-phone-number.module'; +import { + TwilioPhoneNumberAssignment, + TwilioPhoneNumberAssignmentSchema, +} from './schema/twilio-phone-number-assignment.schema'; +import { TwilioPhoneNumberAssignmentService } from './twilio-phone-number-assignment.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: TwilioPhoneNumberAssignment.name, + schema: TwilioPhoneNumberAssignmentSchema, + }, + { name: Subscription.name, schema: SubscriptionSchema }, + ]), + TwilioPhoneNumberModule, + forwardRef(() => SubscriptionModule), + ], + providers: [TwilioPhoneNumberAssignmentService], + exports: [TwilioPhoneNumberAssignmentService, MongooseModule], +}) +export class TwilioPhoneNumberAssignmentModule {} diff --git a/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts new file mode 100644 index 00000000..7cdd3981 --- /dev/null +++ b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts @@ -0,0 +1,298 @@ +import { + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Model, Types } from 'mongoose'; + +import { + Subscription, + SubscriptionDocument, +} from '../subscription/schema/subscription.schema'; +import { SubscriptionService } from '../subscription/subscription.service'; +import { TwilioPhoneNumberService } from '../twilio-phone-number/twilio-phone-number.service'; +import { + TwilioPhoneNumberAssignment, + TwilioPhoneNumberAssignmentDocument, + TwilioPhoneNumberAssignmentStatus, +} from './schema/twilio-phone-number-assignment.schema'; + +@Injectable() +export class TwilioPhoneNumberAssignmentService { + private readonly logger = new Logger(TwilioPhoneNumberAssignmentService.name); + + constructor( + @InjectModel(TwilioPhoneNumberAssignment.name) + private readonly assignmentModel: Model, + @InjectModel(Subscription.name) + private readonly subscriptionModel: Model, + private readonly twilioPhoneNumberService: TwilioPhoneNumberService, + @Inject(forwardRef(() => SubscriptionService)) + private readonly subscriptionService: SubscriptionService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async checkExpiredAssignments(): Promise { + this.logger.log('Checking expired phone number assignments...'); + + const now = new Date(); + const expiredAssignments = await this.assignmentModel + .find({ + status: TwilioPhoneNumberAssignmentStatus.active, + expiresAt: { $lte: now }, + }) + .exec(); + + this.logger.log( + `Found ${String(expiredAssignments.length)} expired assignments`, + ); + + for (const assignment of expiredAssignments) { + try { + const userId = assignment.userId.toString(); + + // Try to find subscription (including all statuses: active, cancelled, pending_cancellation, failed) + let shouldUnbind = true; + const subscription = await this.subscriptionModel + .findOne({ + userId: new Types.ObjectId(userId), + status: { + $in: [ + 'active', + 'cancelled', + 'pending_cancellation', + 'pending_downgrade', + 'failed', + ], + }, + }) + .sort({ createdAt: -1 }) // Get the most recent subscription + .exec(); + + if (subscription) { + // Calculate target expiration: subscription endAt + 7 days + const targetExpiresAt = new Date(subscription.endAt); + targetExpiresAt.setDate(targetExpiresAt.getDate() + 7); + + if (subscription.status === 'active' && subscription.endAt > now) { + // Active subscription: extend to subscription endAt + 7 days + await this.extendExpiration(userId, subscription.endAt); + this.logger.log( + `Extended assignment for user ${userId} (has active subscription until ${subscription.endAt.toISOString()})`, + ); + shouldUnbind = false; + } else if ( + subscription.status === 'active' && + subscription.endAt <= now + ) { + // Active subscription but expired: extend to endAt + 7 days (grace period) + // This handles cases where subscription expired but status hasn't updated yet + if (targetExpiresAt > now) { + await this.extendExpiration(userId, subscription.endAt); + this.logger.log( + `Extended assignment for user ${userId} (active subscription expired, grace period until ${targetExpiresAt.toISOString()})`, + ); + shouldUnbind = false; + } else { + // Grace period also expired: unbind + this.logger.log( + `Subscription grace period expired for user ${userId} (ended at ${subscription.endAt.toISOString()}, grace period ended at ${targetExpiresAt.toISOString()})`, + ); + shouldUnbind = true; + } + } else if ( + (subscription.status === 'cancelled' || + subscription.status === 'pending_cancellation' || + subscription.status === 'failed') && + targetExpiresAt > now + ) { + // Cancelled subscription but still within grace period: extend to endAt + 7 days + await this.extendExpiration(userId, subscription.endAt); + this.logger.log( + `Extended assignment for user ${userId} (cancelled subscription, grace period until ${targetExpiresAt.toISOString()})`, + ); + shouldUnbind = false; + } else if (targetExpiresAt <= now) { + // Subscription ended and grace period expired: unbind + this.logger.log( + `Subscription grace period expired for user ${userId} (ended at ${subscription.endAt.toISOString()}, grace period ended at ${targetExpiresAt.toISOString()})`, + ); + shouldUnbind = true; + } + } else { + // No subscription found: unbind + this.logger.log( + `No subscription found for user ${userId}, unbinding phone number`, + ); + shouldUnbind = true; + } + + if (shouldUnbind) { + await this.unbindPhoneNumber(userId, String(assignment._id)); + this.logger.log(`Unbound phone number for user ${userId}`); + } + } catch (error) { + this.logger.error( + `Failed to process expired assignment ${String(assignment._id)}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + this.logger.log('Finished checking expired assignments'); + } + + async assignPhoneNumber( + userId: string, + subscriptionEndAt: Date, + ): Promise { + const existing = await this.assignmentModel + .findOne({ + userId: new Types.ObjectId(userId), + status: TwilioPhoneNumberAssignmentStatus.active, + }) + .exec(); + + if (existing) { + this.logger.log( + `User ${userId} already has active assignment, extending expiration`, + ); + return this.extendExpiration(userId, subscriptionEndAt); + } + + const availableNumber = await this.twilioPhoneNumberService.getAvailable(); + + if (!availableNumber) { + throw new NotFoundException('No available phone numbers in pool'); + } + + const expiresAt = new Date(subscriptionEndAt); + expiresAt.setDate(expiresAt.getDate() + 7); + + const assignment = await this.assignmentModel.create({ + userId: new Types.ObjectId(userId), + phoneNumberId: availableNumber._id, + phoneNumber: availableNumber.phoneNumber, + assignedAt: new Date(), + expiresAt, + status: TwilioPhoneNumberAssignmentStatus.active, + }); + + await this.twilioPhoneNumberService.markAsAssigned( + String(availableNumber._id), + ); + + this.logger.log( + `Phone number ${availableNumber.phoneNumber} assigned to user ${userId}, expires at ${expiresAt.toISOString()}`, + ); + + return assignment; + } + + async extendExpiration( + userId: string, + subscriptionEndAt: Date, + ): Promise { + const assignment = await this.assignmentModel + .findOne({ + userId: new Types.ObjectId(userId), + status: TwilioPhoneNumberAssignmentStatus.active, + }) + .exec(); + + if (!assignment) { + throw new NotFoundException( + `No active assignment found for user ${userId}`, + ); + } + + const newExpiresAt = new Date(subscriptionEndAt); + newExpiresAt.setDate(newExpiresAt.getDate() + 7); + + assignment.expiresAt = newExpiresAt; + await assignment.save(); + + this.logger.log( + `Extended expiration for user ${userId} to ${newExpiresAt.toISOString()}`, + ); + + return assignment; + } + + async scheduleUnbind(userId: string): Promise { + const assignment = await this.assignmentModel + .findOne({ + userId: new Types.ObjectId(userId), + status: TwilioPhoneNumberAssignmentStatus.active, + }) + .exec(); + + if (!assignment) { + this.logger.warn(`No active assignment found for user ${userId}`); + return; + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + assignment.expiresAt = expiresAt; + await assignment.save(); + + this.logger.log( + `Scheduled unbind for user ${userId}, expires at ${expiresAt.toISOString()}`, + ); + } + + async unbindPhoneNumber(userId: string, assignmentId: string): Promise { + const assignment = await this.assignmentModel.findById(assignmentId); + + if (!assignment) { + throw new NotFoundException(`Assignment ${assignmentId} not found`); + } + + assignment.status = TwilioPhoneNumberAssignmentStatus.expired; + await assignment.save(); + + await this.twilioPhoneNumberService.markAsAvailable( + assignment.phoneNumberId.toString(), + ); + + this.logger.log( + `Phone number ${assignment.phoneNumber} unbound from user ${userId}`, + ); + } + + async getActiveAssignmentByUserId( + userId: string, + ): Promise { + return this.assignmentModel + .findOne({ + userId: new Types.ObjectId(userId), + status: TwilioPhoneNumberAssignmentStatus.active, + }) + .exec(); + } + + async getAssignmentByPhoneNumber( + phoneNumber: string, + ): Promise { + return this.assignmentModel + .findOne({ + phoneNumber, + status: TwilioPhoneNumberAssignmentStatus.active, + }) + .populate('userId') + .exec(); + } + + async getUserByPhoneNumber(phoneNumber: string): Promise { + const assignment = await this.getAssignmentByPhoneNumber(phoneNumber); + if (!assignment) { + return null; + } + return assignment.userId.toString(); + } +} diff --git a/src/modules/twilio-phone-number/dto/create-twilio-phone-number.dto.ts b/src/modules/twilio-phone-number/dto/create-twilio-phone-number.dto.ts new file mode 100644 index 00000000..822d202f --- /dev/null +++ b/src/modules/twilio-phone-number/dto/create-twilio-phone-number.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateTwilioPhoneNumberDto { + @IsNotEmpty() + @IsString() + phoneNumber!: string; +} diff --git a/src/modules/twilio-phone-number/schema/twilio-phone-number.schema.ts b/src/modules/twilio-phone-number/schema/twilio-phone-number.schema.ts new file mode 100644 index 00000000..a786ca4e --- /dev/null +++ b/src/modules/twilio-phone-number/schema/twilio-phone-number.schema.ts @@ -0,0 +1,35 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type TwilioPhoneNumberDocument = TwilioPhoneNumber & Document; + +export enum TwilioPhoneNumberStatus { + available = 'available', + assigned = 'assigned', +} + +@Schema({ timestamps: true }) +export class TwilioPhoneNumber { + @Prop({ required: true, unique: true }) + phoneNumber!: string; + + @Prop({ + type: String, + enum: TwilioPhoneNumberStatus, + default: TwilioPhoneNumberStatus.available, + }) + status!: TwilioPhoneNumberStatus; + + @Prop() + readonly createdAt!: Date; + + @Prop() + readonly updatedAt!: Date; +} + +export const TwilioPhoneNumberSchema = + SchemaFactory.createForClass(TwilioPhoneNumber); + +// Indexes for efficient queries +TwilioPhoneNumberSchema.index({ status: 1 }); +TwilioPhoneNumberSchema.index({ phoneNumber: 1 }); diff --git a/src/modules/twilio-phone-number/twilio-phone-number.controller.ts b/src/modules/twilio-phone-number/twilio-phone-number.controller.ts new file mode 100644 index 00000000..a3b83b97 --- /dev/null +++ b/src/modules/twilio-phone-number/twilio-phone-number.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { CreateTwilioPhoneNumberDto } from './dto/create-twilio-phone-number.dto'; +import { TwilioPhoneNumberDocument } from './schema/twilio-phone-number.schema'; +import { TwilioPhoneNumberService } from './twilio-phone-number.service'; + +@ApiTags('Twilio Phone Numbers') +@Controller('twilio-phone-numbers') +export class TwilioPhoneNumberController { + constructor( + private readonly twilioPhoneNumberService: TwilioPhoneNumberService, + ) {} + + @Post() + async create( + @Body() dto: CreateTwilioPhoneNumberDto, + ): Promise { + return this.twilioPhoneNumberService.create(dto.phoneNumber); + } + + @Get() + async findAll(): Promise { + return this.twilioPhoneNumberService.findAll(); + } + + @Get('available') + async getAvailable(): Promise { + return this.twilioPhoneNumberService.getAvailable(); + } + + @Delete(':id') + async delete(@Param('id') id: string): Promise<{ message: string }> { + await this.twilioPhoneNumberService.delete(id); + return { message: 'Phone number deleted successfully' }; + } +} diff --git a/src/modules/twilio-phone-number/twilio-phone-number.module.ts b/src/modules/twilio-phone-number/twilio-phone-number.module.ts new file mode 100644 index 00000000..9e66f1a5 --- /dev/null +++ b/src/modules/twilio-phone-number/twilio-phone-number.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { + TwilioPhoneNumber, + TwilioPhoneNumberSchema, +} from './schema/twilio-phone-number.schema'; +import { TwilioPhoneNumberController } from './twilio-phone-number.controller'; +import { TwilioPhoneNumberService } from './twilio-phone-number.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: TwilioPhoneNumber.name, schema: TwilioPhoneNumberSchema }, + ]), + ], + controllers: [TwilioPhoneNumberController], + providers: [TwilioPhoneNumberService], + exports: [TwilioPhoneNumberService, MongooseModule], +}) +export class TwilioPhoneNumberModule {} diff --git a/src/modules/twilio-phone-number/twilio-phone-number.service.ts b/src/modules/twilio-phone-number/twilio-phone-number.service.ts new file mode 100644 index 00000000..7a81ca3c --- /dev/null +++ b/src/modules/twilio-phone-number/twilio-phone-number.service.ts @@ -0,0 +1,109 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { + TwilioPhoneNumber, + TwilioPhoneNumberDocument, + TwilioPhoneNumberStatus, +} from './schema/twilio-phone-number.schema'; + +@Injectable() +export class TwilioPhoneNumberService { + private readonly logger = new Logger(TwilioPhoneNumberService.name); + + constructor( + @InjectModel(TwilioPhoneNumber.name) + private readonly twilioPhoneNumberModel: Model, + ) {} + + async create(phoneNumber: string): Promise { + const existing = await this.twilioPhoneNumberModel + .findOne({ phoneNumber }) + .exec(); + if (existing) { + throw new BadRequestException( + `Phone number ${phoneNumber} already exists`, + ); + } + + const newNumber = await this.twilioPhoneNumberModel.create({ + phoneNumber, + status: TwilioPhoneNumberStatus.available, + }); + + this.logger.log(`Phone number ${phoneNumber} added to pool`); + return newNumber; + } + + async getAvailable(): Promise { + return this.twilioPhoneNumberModel + .findOne({ status: TwilioPhoneNumberStatus.available }) + .exec(); + } + + async markAsAssigned(phoneNumberId: string): Promise { + const updated = await this.twilioPhoneNumberModel + .findByIdAndUpdate( + phoneNumberId, + { status: TwilioPhoneNumberStatus.assigned }, + { new: true }, + ) + .exec(); + + if (!updated) { + throw new NotFoundException(`Phone number ${phoneNumberId} not found`); + } + + this.logger.log(`Phone number ${phoneNumberId} marked as assigned`); + } + + async markAsAvailable(phoneNumberId: string): Promise { + const updated = await this.twilioPhoneNumberModel + .findByIdAndUpdate( + phoneNumberId, + { status: TwilioPhoneNumberStatus.available }, + { new: true }, + ) + .exec(); + + if (!updated) { + throw new NotFoundException(`Phone number ${phoneNumberId} not found`); + } + + this.logger.log(`Phone number ${phoneNumberId} marked as available`); + } + + async findById( + phoneNumberId: string, + ): Promise { + return this.twilioPhoneNumberModel.findById(phoneNumberId).exec(); + } + + async findByPhoneNumber( + phoneNumber: string, + ): Promise { + return this.twilioPhoneNumberModel.findOne({ phoneNumber }).exec(); + } + + async findAll(): Promise { + return this.twilioPhoneNumberModel.find().exec(); + } + + async delete(phoneNumberId: string): Promise { + const deleted = await this.twilioPhoneNumberModel + .findByIdAndDelete(phoneNumberId) + .exec(); + + if (!deleted) { + throw new NotFoundException(`Phone number ${phoneNumberId} not found`); + } + + this.logger.log(`Phone number ${phoneNumberId} deleted from pool`); + } +} diff --git a/src/modules/user/schema/user.schema.ts b/src/modules/user/schema/user.schema.ts index d851d40e..ddc2242e 100644 --- a/src/modules/user/schema/user.schema.ts +++ b/src/modules/user/schema/user.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Document, Schema as MongooseSchema } from 'mongoose'; +import { Document } from 'mongoose'; import { EUserRole } from '@/common/constants/user.constant'; diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index b97a59c2..05f56a37 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { TwilioPhoneNumberAssignmentModule } from '@/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module'; import { User, userSchema } from '@/modules/user/schema/user.schema'; import { UserController } from '@/modules/user/user.controller'; import { UserService } from '@/modules/user/user.service'; @@ -8,6 +9,7 @@ import { UserService } from '@/modules/user/user.service'; @Module({ imports: [ MongooseModule.forFeature([{ name: User.name, schema: userSchema }]), + TwilioPhoneNumberAssignmentModule, ], exports: [MongooseModule, UserService], controllers: [UserController], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index dc215417..5bfeeb08 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -10,6 +10,7 @@ import { Model, Types } from 'mongoose'; import { isValidObjectId } from 'mongoose'; import { SALT_ROUNDS } from '@/modules/auth/auth.config'; +import { TwilioPhoneNumberAssignmentService } from '@/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service'; import { AddressDto } from './dto/address.dto'; import { GreetingDto } from './dto/greeting.dto'; @@ -20,6 +21,7 @@ export class UserService { constructor( @InjectModel(User.name) private readonly userModel: Model, + private readonly twilioPhoneNumberAssignmentService: TwilioPhoneNumberAssignmentService, ) {} async findAll(): Promise { @@ -33,6 +35,27 @@ export class UserService { } const user = await this.userModel.findById(id).exec(); if (!user) throw new NotFoundException(`User with id ${id} not found`); + + // Get phone number from assignment table if not already set + const phoneNumber = user.twilioPhoneNumber; + if (phoneNumber === undefined || phoneNumber.trim() === '') { + try { + const assignment = + await this.twilioPhoneNumberAssignmentService.getActiveAssignmentByUserId( + id, + ); + if (assignment) { + // Set phone number on user object for backward compatibility + ( + user as UserDocument & { twilioPhoneNumber: string } + ).twilioPhoneNumber = assignment.phoneNumber; + } + } catch { + // If assignment service fails, continue without phone number + // This ensures backward compatibility + } + } + return user; } @@ -96,6 +119,26 @@ export class UserService { if (typeof twilioPhoneNumber !== 'string') { return null; } + + // First, try to find user through the new assignment table + try { + const userId = + await this.twilioPhoneNumberAssignmentService.getUserByPhoneNumber( + twilioPhoneNumber, + ); + if (userId !== null) { + const user = await this.userModel.findById(userId).exec(); + if (user) { + return user; + } + } + } catch { + // If assignment service fails, fall back to old method + // This ensures backward compatibility + } + + // Fallback to old method for backward compatibility + // (in case phone number is still stored directly in user document) const user = await this.userModel .findOne({ twilioPhoneNumber: { $eq: twilioPhoneNumber } }) .exec(); diff --git a/test/fixtures/static/subscription.ts b/test/fixtures/static/subscription.ts index 2316e12d..ff21a1a5 100644 --- a/test/fixtures/static/subscription.ts +++ b/test/fixtures/static/subscription.ts @@ -18,6 +18,8 @@ export const staticSubscription: Subscription = { endAt: new Date('2024-01-01T00:00:00.000Z'), createdAt: new Date('2023-01-01T00:00:00.000Z'), updatedAt: new Date('2023-01-01T00:00:00.000Z'), + secondsLeft: 1000, + billGranularitySec: 60, } as Subscription; export const staticActiveSubscription: Subscription = { @@ -32,6 +34,8 @@ export const staticActiveSubscription: Subscription = { endAt: new Date('2024-06-01T00:00:00.000Z'), createdAt: new Date('2023-06-01T00:00:00.000Z'), updatedAt: new Date('2023-06-01T00:00:00.000Z'), + secondsLeft: 1000, + billGranularitySec: 60, } as Subscription; export const staticFailedSubscription: Subscription = { @@ -46,6 +50,8 @@ export const staticFailedSubscription: Subscription = { endAt: new Date('2023-04-01T00:00:00.000Z'), createdAt: new Date('2023-03-01T00:00:00.000Z'), updatedAt: new Date('2023-03-01T00:00:00.000Z'), + secondsLeft: 1000, + billGranularitySec: 60, } as Subscription; export const staticCancelledSubscription: Subscription = { @@ -60,4 +66,6 @@ export const staticCancelledSubscription: Subscription = { endAt: new Date('2023-03-01T00:00:00.000Z'), createdAt: new Date('2023-02-01T00:00:00.000Z'), updatedAt: new Date('2023-02-01T00:00:00.000Z'), + secondsLeft: 1000, + billGranularitySec: 60, } as Subscription;