From a8e2f53bf02cc1b3dd523dcfbb768eb6e4cbd815 Mon Sep 17 00:00:00 2001 From: Depeng Sun Date: Wed, 22 Oct 2025 21:26:40 +1030 Subject: [PATCH 1/9] done the minutes --- package.json | 1 + pnpm-lock.yaml | 35 ++++++ src/modules/app.module.ts | 2 + src/modules/company/schema/company.schema.ts | 2 +- .../schema/subscription.schema.ts | 8 ++ .../subscription/subscription.controller.ts | 20 ++++ .../subscription/subscription.service.ts | 103 ++++++++++++++++++ .../services/call-data-persistence.service.ts | 18 +++ .../services/call-processor.service.ts | 6 +- src/modules/telephony/telephony.module.ts | 2 + test/fixtures/static/subscription.ts | 8 ++ 11 files changed, 201 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 19500151..cf0995de 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", 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/src/modules/app.module.ts b/src/modules/app.module.ts index 00a8055f..236065c7 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'; @@ -62,6 +63,7 @@ import { OnboardingModule } from './onboarding/onboarding.module'; UserModule, OnboardingModule, SettingModule, + ScheduleModule.forRoot(), ], providers: [ { 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/subscription/schema/subscription.schema.ts b/src/modules/subscription/schema/subscription.schema.ts index bd600a73..bca23d1b 100644 --- a/src/modules/subscription/schema/subscription.schema.ts +++ b/src/modules/subscription/schema/subscription.schema.ts @@ -29,6 +29,12 @@ export class Subscription { @Prop({ required: true, enum: ['active', 'failed', 'cancelled'] }) status!: 'active' | 'failed' | 'cancelled'; + @Prop({ required: true, default: 0 }) + secondsLeft!: number; + + @Prop({ required: true, default: 60 }) + billGranularitySec!: number; + @Prop({ required: false }) createdAt!: Date; @@ -37,3 +43,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..1185bb56 100644 --- a/src/modules/subscription/subscription.controller.ts +++ b/src/modules/subscription/subscription.controller.ts @@ -44,6 +44,26 @@ 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 }> { + 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({ diff --git a/src/modules/subscription/subscription.service.ts b/src/modules/subscription/subscription.service.ts index 80054f19..6eb7413a 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { Model, Types } from 'mongoose'; import { RRule } from 'rrule'; import Stripe from 'stripe'; @@ -32,6 +33,76 @@ export class SubscriptionService { private readonly stripeService: StripeService, ) {} + @Cron(CronExpression.EVERY_DAY_AT_1AM) // 每天 01:00 跑一次 + 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 = parseInt(plan.features.callMinutes || '0', 10) || 0; + 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 +172,9 @@ export class SubscriptionService { throw new BadRequestException('Could not compute end date from rrule'); } + const includedMinutes = Number(plan.features.callMinutes) || 0; + const billGranularitySec = 60; + await this.subscriptionModel.create({ userId: new Types.ObjectId(userId), planId: new Types.ObjectId(planId), @@ -111,6 +185,8 @@ export class SubscriptionService { status: 'active', startAt: now, endAt, + secondsLeft: includedMinutes * 60, + billGranularitySec, }); return { message: 'Subscription activated', @@ -372,4 +448,31 @@ 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(), + }, + }, + ]); + } } 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..9920d84a 100644 --- a/src/modules/telephony/services/call-processor.service.ts +++ b/src/modules/telephony/services/call-processor.service.ts @@ -29,9 +29,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), diff --git a/src/modules/telephony/telephony.module.ts b/src/modules/telephony/telephony.module.ts index 3133a681..3e94e4e4 100644 --- a/src/modules/telephony/telephony.module.ts +++ b/src/modules/telephony/telephony.module.ts @@ -4,6 +4,7 @@ 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 { UserModule } from '@/modules/user/user.module'; @@ -26,6 +27,7 @@ import { TelephonyService } from './telephony.service'; ServiceModule, ServiceBookingModule, CompanyModule, + SubscriptionModule, ], controllers: [TelephonyController], providers: [ 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; From 5c23a609474c052e8ab378bb18b08b91509d4d70 Mon Sep 17 00:00:00 2001 From: gyx Date: Mon, 27 Oct 2025 17:13:14 +1100 Subject: [PATCH 2/9] feature/stripe-min --- .../stripe/stripe-webhook.controller.ts | 88 +++- .../schema/subscription.schema.ts | 7 +- .../subscription/subscription.controller.ts | 6 +- .../subscription/subscription.service.ts | 447 ++++++++++++++++-- 4 files changed, 488 insertions(+), 60 deletions(-) diff --git a/src/modules/stripe/stripe-webhook.controller.ts b/src/modules/stripe/stripe-webhook.controller.ts index 3aff26c8..c0fd7436 100644 --- a/src/modules/stripe/stripe-webhook.controller.ts +++ b/src/modules/stripe/stripe-webhook.controller.ts @@ -176,6 +176,26 @@ 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 +203,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, ); } @@ -213,6 +239,7 @@ export class StripeWebhookController { this.logger.log(`✅ Payment succeeded for subscription: ${subscriptionId}`); try { + // Update subscription status to active await this.subscriptionService.updateStatusByWebhook( subscriptionId, 'active', @@ -220,9 +247,45 @@ export class StripeWebhookController { this.logger.log( `✅ Subscription ${subscriptionId} status updated to active`, ); + + // Check if this is a recurring payment (not first payment) + // billing_reason: 'subscription_cycle' means recurring payment + const billingReason = invoice.billing_reason; + + if (billingReason === 'subscription_cycle') { + // 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( + `❌ 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, + ); + } else { + // This is the first payment - cycle is already set in activateSubscription + this.logger.log( + `🆕 First payment (${billingReason}), skipping cycle reset`, + ); + } } catch (err) { this.logger.error( - `❌ Failed to update subscription status for ${subscriptionId}`, + `❌ Failed to process payment succeeded for ${subscriptionId}`, err, ); } @@ -232,19 +295,34 @@ export class StripeWebhookController { 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/subscription/schema/subscription.schema.ts b/src/modules/subscription/schema/subscription.schema.ts index bca23d1b..e498af88 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,8 @@ 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; diff --git a/src/modules/subscription/subscription.controller.ts b/src/modules/subscription/subscription.controller.ts index 1185bb56..e16efd1d 100644 --- a/src/modules/subscription/subscription.controller.ts +++ b/src/modules/subscription/subscription.controller.ts @@ -96,10 +96,10 @@ 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.service.ts b/src/modules/subscription/subscription.service.ts index 6eb7413a..5559cc7a 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Cron, CronExpression } from '@nestjs/schedule'; +// import { Cron, CronExpression } from '@nestjs/schedule'; // Disabled: using webhook-based reset import { Model, Types } from 'mongoose'; import { RRule } from 'rrule'; import Stripe from 'stripe'; @@ -33,7 +33,40 @@ export class SubscriptionService { private readonly stripeService: StripeService, ) {} - @Cron(CronExpression.EVERY_DAY_AT_1AM) // 每天 01:00 跑一次 + /** + * 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 + const match = callMinutes.toString().match(/(\d+)/); + if (match) { + return parseInt(match[1], 10); + } + + // If no number found (e.g., "Unlimited"), return 0 + return 0; + } + + /** + * 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'); @@ -75,7 +108,7 @@ export class SubscriptionService { newEnd = n; } - const minutes = parseInt(plan.features.callMinutes || '0', 10) || 0; + const minutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); const gran = sub.billGranularitySec; await this.subscriptionModel.updateOne( @@ -172,7 +205,7 @@ export class SubscriptionService { throw new BadRequestException('Could not compute end date from rrule'); } - const includedMinutes = Number(plan.features.callMinutes) || 0; + const includedMinutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); const billGranularitySec = 60; await this.subscriptionModel.create({ @@ -188,18 +221,25 @@ export class SubscriptionService { secondsLeft: includedMinutes * 60, billGranularitySec, }); + 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'); @@ -207,11 +247,60 @@ export class SubscriptionService { if (!Types.ObjectId.isValid(newPlanId)) { throw new BadRequestException('Invalid plan ID'); } - const plan = await this.planModel.findById(newPlanId); + const newPlan = await this.planModel.findById(newPlanId); - if (!plan) throw new NotFoundException('Plan not found'); + if (!newPlan) throw new NotFoundException('Plan not found'); - if (subscription.planId.equals(plan._id)) { + // 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') { + this.logger.log( + `🔄 Canceling scheduled downgrade for subscription: ${subscription.subscriptionId}`, + ); + + if (!subscription.subscriptionId) { + throw new BadRequestException('Missing subscription ID'); + } + + // 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' }; } @@ -219,26 +308,127 @@ 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}: ${currentMinutes} → ${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 ${newMinutes} minutes immediately`, + ); + } else { + // Downgrade: Takes effect at next billing cycle + this.logger.log( + `⬇️ Downgrading subscription ${subscription.subscriptionId}: ${currentMinutes} → ${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 ${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, @@ -253,13 +443,39 @@ 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}: ${newMinutes} minutes`, ); } @@ -279,6 +495,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, @@ -301,8 +548,12 @@ 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'] } + }) .populate('planId') + .populate('pendingPlanId') .populate('userId'); if (!subscription) @@ -346,57 +597,153 @@ export class SubscriptionService { return this.subscriptionModel.findOne({ subscriptionId }); } - async downgradeToFree(userId: string): Promise { + /** + * 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({ - userId: new Types.ObjectId(userId), - status: 'active', + subscriptionId, + status: { $in: ['active', 'pending_downgrade', 'pending_cancellation', 'cancelled'] }, }); - if (!subscription) - throw new NotFoundException('Active subscription not found'); + if (!subscription) { + this.logger.warn( + `No active, pending_downgrade, pending_cancellation, or cancelled subscription found for subscriptionId: ${subscriptionId}`, + ); + return; + } - if (subscription.subscriptionId == null) { - throw new BadRequestException('Missing subscription ID'); + // For pending_downgrade, use pendingPlanId; otherwise use current planId + const targetPlanId = subscription.status === 'pending_downgrade' && subscription.pendingPlanId + ? subscription.pendingPlanId + : subscription.planId; + + const plan = await this.planModel.findById(targetPlanId).lean(); + if (!plan) { + this.logger.error( + `Plan not found for subscription: ${subscriptionId}, planId: ${targetPlanId}`, + ); + 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: any = { + 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} to ${targetPlanId}, 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 - if (subscription.chargeId == null) { - throw new BadRequestException('Missing charge ID for refund'); + const updateOperations: any = { $set: updateData }; + + // Clear pendingPlanId if it was a downgrade + if (subscription.status === 'pending_downgrade' && subscription.pendingPlanId) { + updateOperations.$unset = { pendingPlanId: '' }; } - const stripeSub = await this.stripeService.client.subscriptions.retrieve( - subscription.subscriptionId, + await this.subscriptionModel.updateOne( + { subscriptionId }, + updateOperations, ); - const currentPeriodStart = - stripeSub.items.data[0].current_period_start * 1000; - const currentPeriodEnd = stripeSub.items.data[0].current_period_end * 1000; - const now = Date.now(); + this.logger.log( + `✅ Subscription cycle reset for ${subscriptionId}: ${startAt.toISOString()} - ${endAt.toISOString()}, ${minutes} minutes restored`, + ); + } - const remainingTime = Math.max(currentPeriodEnd - now, 0); - const totalPeriodTime = currentPeriodEnd - currentPeriodStart; - const remainingPercentage = remainingTime / totalPeriodTime; + + /** + * 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) + */ + async downgradeToFree(userId: string): Promise { + const subscription = await this.subscriptionModel.findOne({ + userId: new Types.ObjectId(userId), + status: { $in: ['active', 'pending_downgrade'] }, + }); - const invoice = await this.stripeService.client.invoices.retrieve( - stripeSub.latest_invoice as string, - ); + if (!subscription) + throw new NotFoundException('Active or pending_downgrade subscription not found'); - const amountPaid = invoice.amount_paid; - const refundAmount = Math.floor(amountPaid * remainingPercentage); + if (subscription.subscriptionId == null) { + throw new BadRequestException('Missing subscription ID'); + } - if (refundAmount > 0) { - await this.stripeService.refundPayment( - subscription.chargeId, - refundAmount, + // 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}`, + ); + + // 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' }, + ); + + this.logger.log( + `⏳ Subscription scheduled for cancellation at period end for user ${userId}, subscriptionId: ${subscription.subscriptionId}`, ); } From 4d625003d1c8e9500ab4709e7ed02fb05da0b7c9 Mon Sep 17 00:00:00 2001 From: gyx Date: Tue, 28 Oct 2025 13:04:19 +1100 Subject: [PATCH 3/9] feature/min-stripe --- .../stripe/stripe-webhook.controller.ts | 102 ++++++++++-------- src/modules/stripe/stripe.service.ts | 2 +- .../subscription/subscription.service.ts | 42 +++++++- 3 files changed, 95 insertions(+), 51 deletions(-) diff --git a/src/modules/stripe/stripe-webhook.controller.ts b/src/modules/stripe/stripe-webhook.controller.ts index c0fd7436..0034cf48 100644 --- a/src/modules/stripe/stripe-webhook.controller.ts +++ b/src/modules/stripe/stripe-webhook.controller.ts @@ -219,17 +219,17 @@ export class StripeWebhookController { private async handlePaymentSucceeded(event: Stripe.Event): Promise { const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = invoice.parent?.subscription_details - ?.subscription as string; + 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 = - await this.subscriptionService.findBySuscriptionId(subscriptionId); - if (!check) { + // Single database query to get subscription + const subscription = await this.subscriptionService.findBySuscriptionId(subscriptionId); + if (!subscription) { this.logger.warn( `[Webhook] ⚠️ Subscription ${subscriptionId} not found. Probably not created yet. Skipping.`, ); @@ -239,50 +239,25 @@ export class StripeWebhookController { this.logger.log(`✅ Payment succeeded for subscription: ${subscriptionId}`); try { - // Update subscription status to active - await this.subscriptionService.updateStatusByWebhook( - subscriptionId, - 'active', - ); - this.logger.log( - `✅ Subscription ${subscriptionId} status updated to active`, - ); - - // Check if this is a recurring payment (not first payment) - // billing_reason: 'subscription_cycle' means recurring payment - const billingReason = invoice.billing_reason; - - if (billingReason === 'subscription_cycle') { - // 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( - `❌ 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}`, - ); + // Early return for cancelled subscriptions - no need to process further + if (subscription.status === 'cancelled' || subscription.status === 'pending_cancellation') { this.logger.log( - `📅 Period: ${new Date(periodStart * 1000).toISOString()} - ${new Date(periodEnd * 1000).toISOString()}`, - ); - - await this.subscriptionService.resetSubscriptionCycleWithPeriod( - subscriptionId, - periodStart, - periodEnd, + `⏸️ 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 is the first payment - cycle is already set in activateSubscription - this.logger.log( - `🆕 First payment (${billingReason}), skipping cycle reset`, - ); + 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}`, @@ -291,6 +266,41 @@ export class StripeWebhookController { } } + /** + * 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(`🆕 First payment (${billingReason}), skipping cycle reset`); + 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(`❌ 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; 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/subscription.service.ts b/src/modules/subscription/subscription.service.ts index 5559cc7a..9778a85f 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -550,7 +550,7 @@ export class SubscriptionService { const subscription = await this.subscriptionModel .findOne({ userId: new Types.ObjectId(userId), - status: { $in: ['active', 'pending_cancellation', 'pending_downgrade'] } + status: { $in: ['active', 'pending_cancellation', 'pending_downgrade', 'failed'] } }) .populate('planId') .populate('pendingPlanId') @@ -624,10 +624,17 @@ export class SubscriptionService { } // For pending_downgrade, use pendingPlanId; otherwise use current planId - const targetPlanId = subscription.status === 'pending_downgrade' && subscription.pendingPlanId + 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}, pendingPlanId=${subscription.pendingPlanId}, targetPlanId=${targetPlanId}`, + ); + } + const plan = await this.planModel.findById(targetPlanId).lean(); if (!plan) { this.logger.error( @@ -699,20 +706,47 @@ export class SubscriptionService { * 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: { $in: ['active', 'pending_downgrade'] }, + status: { $in: ['active', 'pending_downgrade', 'failed'] }, }); if (!subscription) - throw new NotFoundException('Active or pending_downgrade subscription not found'); + throw new NotFoundException('Active, pending_downgrade, or failed subscription not found'); if (subscription.subscriptionId == null) { throw new BadRequestException('Missing subscription ID'); } + // Handle failed subscription - cancel immediately + if (subscription.status === 'failed') { + this.logger.log( + `🚫 Canceling failed subscription immediately: ${subscription.subscriptionId}`, + ); + + // Cancel the subscription immediately on Stripe + await this.stripeService.client.subscriptions.cancel( + subscription.subscriptionId, + ); + + // Update subscription status to cancelled in database + await this.subscriptionModel.updateOne( + { subscriptionId: subscription.subscriptionId }, + { + status: 'cancelled', + secondsLeft: 0, // Suspend service immediately + }, + ); + + this.logger.log( + `✅ Failed subscription cancelled immediately for user ${userId}, subscriptionId: ${subscription.subscriptionId}`, + ); + return; + } + // If subscription is pending_downgrade, we need to cancel the scheduled downgrade first // by retrieving current subscription details if (subscription.status === 'pending_downgrade') { From 904e828694e42358dc3b213cdc5814424f0e3057 Mon Sep 17 00:00:00 2001 From: "ewan.yxg@gmail.com" Date: Wed, 5 Nov 2025 12:05:11 +1100 Subject: [PATCH 4/9] fix/lint --- .../stripe/stripe-webhook.controller.ts | 6 +-- .../subscription/subscription.service.ts | 44 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/modules/stripe/stripe-webhook.controller.ts b/src/modules/stripe/stripe-webhook.controller.ts index 0034cf48..44362645 100644 --- a/src/modules/stripe/stripe-webhook.controller.ts +++ b/src/modules/stripe/stripe-webhook.controller.ts @@ -275,13 +275,13 @@ export class StripeWebhookController { // Early return for non-recurring payments if (billingReason !== 'subscription_cycle') { - this.logger.log(`🆕 First payment (${billingReason}), skipping cycle reset`); + this.logger.log(`🆕 First payment (${billingReason ?? 'unknown'}), skipping cycle reset`); return; } // Extract period information from invoice lines - const periodStart = invoice.lines?.data[0]?.period?.start; - const periodEnd = invoice.lines?.data[0]?.period?.end; + const periodStart = invoice.lines.data[0]?.period?.start; + const periodEnd = invoice.lines.data[0]?.period?.end; if (!periodStart || !periodEnd) { this.logger.error(`❌ Missing period information in invoice for ${subscriptionId}`); diff --git a/src/modules/subscription/subscription.service.ts b/src/modules/subscription/subscription.service.ts index 9778a85f..3adc04d8 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; // import { Cron, CronExpression } from '@nestjs/schedule'; // Disabled: using webhook-based reset -import { Model, Types } from 'mongoose'; +import { Model, Types, UpdateQuery } from 'mongoose'; import { RRule } from 'rrule'; import Stripe from 'stripe'; @@ -50,7 +50,8 @@ export class SubscriptionService { } // If it's a string, extract the numeric part - const match = callMinutes.toString().match(/(\d+)/); + // At this point callMinutes must be a string (checked above) + const match = callMinutes.match(/(\d+)/); if (match) { return parseInt(match[1], 10); } @@ -254,14 +255,14 @@ export class SubscriptionService { // 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') { - this.logger.log( - `🔄 Canceling scheduled downgrade for subscription: ${subscription.subscriptionId}`, - ); - if (!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, @@ -353,7 +354,7 @@ export class SubscriptionService { if (isUpgrade) { // Upgrade: Immediate effect with proration this.logger.log( - `⬆️ Upgrading subscription ${subscription.subscriptionId}: ${currentMinutes} → ${newMinutes} minutes`, + `⬆️ Upgrading subscription ${subscription.subscriptionId}: ${String(currentMinutes)} → ${String(newMinutes)} minutes`, ); await this.stripeService.client.subscriptions.update( @@ -381,12 +382,12 @@ export class SubscriptionService { ); this.logger.log( - `✅ Upgrade completed: secondsLeft updated to ${newMinutes} minutes immediately`, + `✅ Upgrade completed: secondsLeft updated to ${String(newMinutes)} minutes immediately`, ); } else { // Downgrade: Takes effect at next billing cycle this.logger.log( - `⬇️ Downgrading subscription ${subscription.subscriptionId}: ${currentMinutes} → ${newMinutes} minutes (next cycle)`, + `⬇️ Downgrading subscription ${subscription.subscriptionId}: ${String(currentMinutes)} → ${String(newMinutes)} minutes (next cycle)`, ); await this.stripeService.client.subscriptions.update( @@ -415,7 +416,7 @@ export class SubscriptionService { ); this.logger.log( - `✅ Downgrade scheduled: pendingPlanId set to ${newPlan.tier}, status set to pending_downgrade, will take effect at next billing cycle, current ${currentMinutes} minutes maintained`, + `✅ Downgrade scheduled: pendingPlanId set to ${newPlan.tier}, status set to pending_downgrade, will take effect at next billing cycle, current ${String(currentMinutes)} minutes maintained`, ); } @@ -475,7 +476,7 @@ export class SubscriptionService { ); this.logger.log( - `✅ Plan updated via webhook for ${stripeSubscriptionId}: ${newMinutes} minutes`, + `✅ Plan updated via webhook for ${stripeSubscriptionId}: ${String(newMinutes)} minutes`, ); } @@ -631,14 +632,14 @@ export class SubscriptionService { // Debug logging for downgrade if (subscription.status === 'pending_downgrade') { this.logger.log( - `🔍 Downgrade debug for ${subscriptionId}: status=${subscription.status}, currentPlanId=${subscription.planId}, pendingPlanId=${subscription.pendingPlanId}, targetPlanId=${targetPlanId}`, + `🔍 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}`, + `Plan not found for subscription: ${subscriptionId}, planId: ${targetPlanId.toString()}`, ); return; } @@ -652,7 +653,14 @@ export class SubscriptionService { const secondsLeft = minutes * 60; // Prepare update data - const updateData: any = { + const updateData: { + startAt: Date; + endAt: Date; + secondsLeft: number; + updatedAt: Date; + status?: string; + planId?: Types.ObjectId; + } = { startAt, endAt, secondsLeft, @@ -665,7 +673,7 @@ export class SubscriptionService { 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} to ${targetPlanId}, status changed to active`, + `🔄 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 @@ -682,10 +690,10 @@ export class SubscriptionService { } // For 'active' status, no additional changes needed - const updateOperations: any = { $set: updateData }; + const updateOperations: UpdateQuery = { $set: updateData }; // Clear pendingPlanId if it was a downgrade - if (subscription.status === 'pending_downgrade' && subscription.pendingPlanId) { + if (subscription.status === 'pending_downgrade' && subscription.pendingPlanId != null) { updateOperations.$unset = { pendingPlanId: '' }; } @@ -695,7 +703,7 @@ export class SubscriptionService { ); this.logger.log( - `✅ Subscription cycle reset for ${subscriptionId}: ${startAt.toISOString()} - ${endAt.toISOString()}, ${minutes} minutes restored`, + `✅ Subscription cycle reset for ${subscriptionId}: ${startAt.toISOString()} - ${endAt.toISOString()}, ${String(minutes)} minutes restored`, ); } From 25f3317c6b2f44e8f829da05126acb4678df7d48 Mon Sep 17 00:00:00 2001 From: "ewan.yxg@gmail.com" Date: Wed, 5 Nov 2025 12:08:07 +1100 Subject: [PATCH 5/9] fix/lint --- src/modules/subscription/subscription.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/subscription/subscription.controller.ts b/src/modules/subscription/subscription.controller.ts index e16efd1d..fe251af2 100644 --- a/src/modules/subscription/subscription.controller.ts +++ b/src/modules/subscription/subscription.controller.ts @@ -49,6 +49,7 @@ export class SubscriptionController { @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 }; } From a05d01f42f7bcf4dd414fce902057add7682134a Mon Sep 17 00:00:00 2001 From: Depeng Sun Date: Sat, 8 Nov 2025 17:34:01 +1030 Subject: [PATCH 6/9] Enhance subscription and user modules with Twilio phone number assignment integration. Refactor database connection logic for improved clarity. Update Google Calendar token handling for better type safety and error handling. Clean up unused imports and improve code readability across various services. --- src/modules/app.module.ts | 4 + src/modules/company/dto/create-company.dto.ts | 13 +- src/modules/database/database.module.ts | 14 +- .../calendar-token.controller.ts | 45 ++- .../google-calendar/calendar-token.service.ts | 17 +- .../schema/calendar-token.schema.ts | 2 - .../mcp-calendar-integration.service.ts | 48 ++- src/modules/setting/setting.service.ts | 2 +- src/modules/setting/verification.service.ts | 2 +- .../stripe/stripe-webhook.controller.ts | 57 +++- .../schema/subscription.schema.ts | 18 +- .../subscription/subscription.controller.ts | 5 +- .../subscription/subscription.module.ts | 2 + .../subscription/subscription.service.ts | 225 +++++++++----- .../services/call-processor.service.ts | 23 +- src/modules/telephony/telephony.module.ts | 2 + .../twilio-phone-number-assignment.schema.ts | 54 ++++ .../twilio-phone-number-assignment.module.ts | 31 ++ .../twilio-phone-number-assignment.service.ts | 291 ++++++++++++++++++ .../dto/create-twilio-phone-number.dto.ts | 7 + .../schema/twilio-phone-number.schema.ts | 35 +++ .../twilio-phone-number.controller.ts | 37 +++ .../twilio-phone-number.module.ts | 21 ++ .../twilio-phone-number.service.ts | 109 +++++++ src/modules/user/schema/user.schema.ts | 2 +- src/modules/user/user.module.ts | 2 + src/modules/user/user.service.ts | 43 +++ 27 files changed, 957 insertions(+), 154 deletions(-) create mode 100644 src/modules/twilio-phone-number-assignment/schema/twilio-phone-number-assignment.schema.ts create mode 100644 src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.module.ts create mode 100644 src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts create mode 100644 src/modules/twilio-phone-number/dto/create-twilio-phone-number.dto.ts create mode 100644 src/modules/twilio-phone-number/schema/twilio-phone-number.schema.ts create mode 100644 src/modules/twilio-phone-number/twilio-phone-number.controller.ts create mode 100644 src/modules/twilio-phone-number/twilio-phone-number.module.ts create mode 100644 src/modules/twilio-phone-number/twilio-phone-number.service.ts diff --git a/src/modules/app.module.ts b/src/modules/app.module.ts index 236065c7..5891a870 100755 --- a/src/modules/app.module.ts +++ b/src/modules/app.module.ts @@ -28,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'; @@ -56,6 +58,8 @@ import { OnboardingModule } from './onboarding/onboarding.module'; ServiceLocationMappingModule, TelephonyModule, TwilioModule, + TwilioPhoneNumberModule, + TwilioPhoneNumberAssignmentModule, RedisModule, AiHttpModule, SubscriptionModule, 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/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..c8ef55da 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,27 @@ export class CalendarTokenController { description: 'Token created/updated successfully', }) @Post() - async createOrUpdateToken(@Body() createDto: CreateCalendarTokenDto) { + async createOrUpdateToken( + @Body() createDto: CreateCalendarTokenDto, + ): Promise<{ message: string }> { return await this.calendarTokenService.createOrUpdateToken(createDto); } @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 +63,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..9843834b 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, }; @@ -260,13 +263,36 @@ Service: ${callData.serviceType} /** * Call MCP AI backend to create calendar event and send email. + * TODO: Implement actual API call when MCP backend is ready */ - async callMcpAiBackend( + callMcpAiBackend( userId: string, - mcpParams: any, - emailData: any, - calendarData: any, - ): Promise { + 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[]; + }, + ): Promise<{ + success: boolean; + eventId: string; + emailSent: boolean; + message: string; + timestamp: string; + }> { try { // Build MCP API request payload const mcpRequest = { @@ -278,7 +304,7 @@ Service: ${callData.serviceType} }; this.logger.log(`Calling MCP AI backend, user: ${userId}`, { - hasAccessToken: !!mcpRequest.accessToken, + hasAccessToken: mcpRequest.accessToken !== '', calendarId: mcpRequest.calendarId, eventSummary: mcpRequest.summary, emailTo: mcpRequest.to, 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 44362645..fcd74709 100644 --- a/src/modules/stripe/stripe-webhook.controller.ts +++ b/src/modules/stripe/stripe-webhook.controller.ts @@ -188,7 +188,10 @@ export class StripeWebhookController { } // Skip if subscription is already cancelled or pending cancellation - if (subscription.status === 'cancelled' || subscription.status === 'pending_cancellation') { + if ( + subscription.status === 'cancelled' || + subscription.status === 'pending_cancellation' + ) { this.logger.log( `⏭️ Subscription ${subscriptionId} is ${subscription.status}. Skipping payment failed handling.`, ); @@ -219,7 +222,8 @@ export class StripeWebhookController { private async handlePaymentSucceeded(event: Stripe.Event): Promise { const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = invoice.parent?.subscription_details?.subscription as string; + const subscriptionId = invoice.parent?.subscription_details + ?.subscription as string; // Early validation - extract subscriptionId and validate if (!subscriptionId) { @@ -228,7 +232,8 @@ export class StripeWebhookController { } // Single database query to get subscription - const subscription = await this.subscriptionService.findBySuscriptionId(subscriptionId); + const subscription = + await this.subscriptionService.findBySuscriptionId(subscriptionId); if (!subscription) { this.logger.warn( `[Webhook] ⚠️ Subscription ${subscriptionId} not found. Probably not created yet. Skipping.`, @@ -240,7 +245,10 @@ export class StripeWebhookController { try { // Early return for cancelled subscriptions - no need to process further - if (subscription.status === 'cancelled' || subscription.status === 'pending_cancellation') { + if ( + subscription.status === 'cancelled' || + subscription.status === 'pending_cancellation' + ) { this.logger.log( `⏸️ Subscription ${subscriptionId} is ${subscription.status}, skipping payment processing`, ); @@ -250,10 +258,17 @@ export class StripeWebhookController { // 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`); + 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`); + this.logger.log( + `⏸️ Subscription ${subscriptionId} is pending_downgrade, keeping status unchanged`, + ); } // Process recurring payment cycle reset @@ -270,12 +285,17 @@ export class StripeWebhookController { * Process recurring payment cycle reset * Extracted for better code organization and reusability */ - private async processRecurringPayment(subscriptionId: string, invoice: Stripe.Invoice): Promise { + 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(`🆕 First payment (${billingReason ?? 'unknown'}), skipping cycle reset`); + this.logger.log( + `🆕 First payment (${billingReason ?? 'unknown'}), skipping cycle reset`, + ); return; } @@ -284,16 +304,20 @@ export class StripeWebhookController { const periodEnd = invoice.lines.data[0]?.period?.end; if (!periodStart || !periodEnd) { - this.logger.error(`❌ Missing period information in invoice for ${subscriptionId}`); + this.logger.error( + `❌ 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( + `🔄 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, @@ -309,8 +333,9 @@ export class StripeWebhookController { try { // Check if subscription exists in database - const dbSubscription = await this.subscriptionService.findBySuscriptionId(subscriptionId); - + const dbSubscription = + await this.subscriptionService.findBySuscriptionId(subscriptionId); + if (!dbSubscription) { this.logger.warn( `[Webhook] ⚠️ Subscription ${subscriptionId} not found in database. Skipping.`, @@ -323,10 +348,10 @@ export class StripeWebhookController { subscriptionId, 'cancelled', ); - + // Set secondsLeft to 0 when subscription is cancelled await this.subscriptionService.suspendSubscription(subscriptionId); - + this.logger.log( `✅ Subscription ${subscriptionId} status updated to cancelled and suspended`, ); diff --git a/src/modules/subscription/schema/subscription.schema.ts b/src/modules/subscription/schema/subscription.schema.ts index e498af88..c3315161 100644 --- a/src/modules/subscription/schema/subscription.schema.ts +++ b/src/modules/subscription/schema/subscription.schema.ts @@ -29,8 +29,22 @@ export class Subscription { @Prop({ required: false }) endAt!: Date; - @Prop({ required: true, enum: ['active', 'failed', 'cancelled', 'pending_cancellation', 'pending_downgrade'] }) - status!: 'active' | 'failed' | 'cancelled' | 'pending_cancellation' | 'pending_downgrade'; + @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; diff --git a/src/modules/subscription/subscription.controller.ts b/src/modules/subscription/subscription.controller.ts index fe251af2..e92c6915 100644 --- a/src/modules/subscription/subscription.controller.ts +++ b/src/modules/subscription/subscription.controller.ts @@ -98,7 +98,10 @@ export class SubscriptionController { @Patch(':userId/free') @ApiOperation({ summary: 'Schedule subscription cancellation at period end' }) - @ApiResponse({ status: 200, description: 'Subscription set to cancel at period end' }) + @ApiResponse({ + status: 200, + description: 'Subscription set to cancel at period end', + }) @ApiResponse({ status: 404, description: 'Active subscription not found' }) @ApiResponse({ status: 400, description: 'Invalid subscription data' }) async downgradeToFree(@Param('userId') userId: string): Promise { diff --git a/src/modules/subscription/subscription.module.ts b/src/modules/subscription/subscription.module.ts index 45e18b92..fbf898df 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), + TwilioPhoneNumberAssignmentModule, ], controllers: [SubscriptionController], providers: [SubscriptionService], diff --git a/src/modules/subscription/subscription.service.ts b/src/modules/subscription/subscription.service.ts index 3adc04d8..d6de0c3a 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -12,6 +12,7 @@ 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 { @@ -31,39 +32,13 @@ export class SubscriptionService { @InjectModel(User.name) private readonly UserModel: Model, private readonly stripeService: StripeService, + private readonly twilioPhoneNumberAssignmentService: TwilioPhoneNumberAssignmentService, ) {} - /** - * 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 = callMinutes.match(/(\d+)/); - if (match) { - return parseInt(match[1], 10); - } - - // If no number found (e.g., "Unlimited"), return 0 - return 0; - } - /** * 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 */ @@ -109,7 +84,9 @@ export class SubscriptionService { newEnd = n; } - const minutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); + const minutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); const gran = sub.billGranularitySec; await this.subscriptionModel.updateOne( @@ -206,7 +183,9 @@ export class SubscriptionService { throw new BadRequestException('Could not compute end date from rrule'); } - const includedMinutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); + const includedMinutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); const billGranularitySec = 60; await this.subscriptionModel.create({ @@ -223,6 +202,22 @@ export class SubscriptionService { 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', }; @@ -255,29 +250,33 @@ export class SubscriptionService { // 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) { + 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, - ); - + 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, { @@ -286,20 +285,20 @@ export class SubscriptionService { 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 + $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' }; @@ -316,8 +315,12 @@ export class SubscriptionService { } // Extract call minutes for comparison - const currentMinutes = this.extractMinutesFromCallMinutes(currentPlan.features.callMinutes); - const newMinutes = this.extractMinutesFromCallMinutes(newPlan.features.callMinutes); + 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( @@ -331,7 +334,7 @@ export class SubscriptionService { this.logger.log( `🔄 Canceling scheduled cancellation for subscription: ${subscription.subscriptionId}`, ); - + await this.stripeService.client.subscriptions.update( subscription.subscriptionId, { @@ -344,7 +347,7 @@ export class SubscriptionService { { subscriptionId: subscription.subscriptionId }, { status: 'active' }, ); - + this.logger.log( `✅ Subscription ${subscription.subscriptionId} cancellation canceled and status restored to active`, ); @@ -408,7 +411,7 @@ export class SubscriptionService { { subscriptionId: subscription.subscriptionId }, { $set: { - pendingPlanId: newPlan._id, // Track the pending downgrade plan + pendingPlanId: newPlan._id, // Track the pending downgrade plan status: 'pending_downgrade', updatedAt: new Date(), }, @@ -420,13 +423,15 @@ export class SubscriptionService { ); } - return { message: `Plan ${isUpgrade ? 'upgraded' : 'downgraded'} successfully` }; + 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) */ @@ -459,7 +464,9 @@ export class SubscriptionService { } // Extract call minutes from the new plan - const newMinutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); + const newMinutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); const newSecondsLeft = newMinutes * 60; // Update both planId and secondsLeft @@ -549,12 +556,19 @@ export class SubscriptionService { async getActiveByuser(userId: string): Promise { const subscription = await this.subscriptionModel - .findOne({ - userId: new Types.ObjectId(userId), - status: { $in: ['active', 'pending_cancellation', 'pending_downgrade', 'failed'] } + .findOne({ + userId: new Types.ObjectId(userId), + status: { + $in: [ + 'active', + 'pending_cancellation', + 'pending_downgrade', + 'failed', + ], + }, }) .populate('planId') - .populate('pendingPlanId') + .populate('pendingPlanId') .populate('userId'); if (!subscription) @@ -602,7 +616,7 @@ export class SubscriptionService { * 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 @@ -614,7 +628,14 @@ export class SubscriptionService { ): Promise { const subscription = await this.subscriptionModel.findOne({ subscriptionId, - status: { $in: ['active', 'pending_downgrade', 'pending_cancellation', 'cancelled'] }, + status: { + $in: [ + 'active', + 'pending_downgrade', + 'pending_cancellation', + 'cancelled', + ], + }, }); if (!subscription) { @@ -625,9 +646,11 @@ export class SubscriptionService { } // For pending_downgrade, use pendingPlanId; otherwise use current planId - const targetPlanId = subscription.status === 'pending_downgrade' && subscription.pendingPlanId != null - ? subscription.pendingPlanId - : subscription.planId; + const targetPlanId = + subscription.status === 'pending_downgrade' && + subscription.pendingPlanId != null + ? subscription.pendingPlanId + : subscription.planId; // Debug logging for downgrade if (subscription.status === 'pending_downgrade') { @@ -649,7 +672,9 @@ export class SubscriptionService { const endAt = new Date(periodEnd * 1000); // Reset call minutes based on plan - const minutes = this.extractMinutesFromCallMinutes(plan.features.callMinutes); + const minutes = this.extractMinutesFromCallMinutes( + plan.features.callMinutes, + ); const secondsLeft = minutes * 60; // Prepare update data @@ -671,7 +696,7 @@ export class SubscriptionService { if (subscription.status === 'pending_downgrade') { // Downgrade has now taken effect updateData.status = 'active'; - updateData.planId = targetPlanId; // Update planId to the pending plan + 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`, ); @@ -690,13 +715,35 @@ export class SubscriptionService { } // For 'active' status, no additional changes needed - const updateOperations: UpdateQuery = { $set: updateData }; - + const updateOperations: UpdateQuery = { + $set: updateData, + }; + // Clear pendingPlanId if it was a downgrade - if (subscription.status === 'pending_downgrade' && subscription.pendingPlanId != null) { + 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, @@ -707,11 +754,10 @@ export class SubscriptionService { ); } - /** * Schedule subscription cancellation at period end * User can continue using service until current period ends - * Status changes: + * Status changes: * - active → pending_cancellation → cancelled (via webhook) * - pending_downgrade → pending_cancellation → cancelled (via webhook) * - failed → cancelled (immediate cancellation) @@ -723,7 +769,9 @@ export class SubscriptionService { }); if (!subscription) - throw new NotFoundException('Active, pending_downgrade, or failed subscription not found'); + throw new NotFoundException( + 'Active, pending_downgrade, or failed subscription not found', + ); if (subscription.subscriptionId == null) { throw new BadRequestException('Missing subscription ID'); @@ -734,7 +782,7 @@ export class SubscriptionService { this.logger.log( `🚫 Canceling failed subscription immediately: ${subscription.subscriptionId}`, ); - + // Cancel the subscription immediately on Stripe await this.stripeService.client.subscriptions.cancel( subscription.subscriptionId, @@ -743,12 +791,15 @@ export class SubscriptionService { // Update subscription status to cancelled in database await this.subscriptionModel.updateOne( { subscriptionId: subscription.subscriptionId }, - { + { status: 'cancelled', secondsLeft: 0, // Suspend service immediately }, ); + // Phone number will be automatically handled by cron job + // Cron job will check subscription.endAt + 7 days and unbind when expired + this.logger.log( `✅ Failed subscription cancelled immediately for user ${userId}, subscriptionId: ${subscription.subscriptionId}`, ); @@ -761,7 +812,7 @@ export class SubscriptionService { this.logger.log( `🔄 Canceling scheduled downgrade for subscription: ${subscription.subscriptionId}`, ); - + // Stripe will handle the scheduled change when we set cancel_at_period_end // The scheduled price change will be discarded this.logger.log( @@ -784,6 +835,9 @@ export class SubscriptionService { { 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}`, ); @@ -864,4 +918,33 @@ export class SubscriptionService { }, ]); } + + /** + * 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-processor.service.ts b/src/modules/telephony/services/call-processor.service.ts index 9920d84a..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'; @@ -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 3e94e4e4..2b9f25c7 100644 --- a/src/modules/telephony/telephony.module.ts +++ b/src/modules/telephony/telephony.module.ts @@ -7,6 +7,7 @@ import { ServiceBookingModule } from '@/modules/service-booking/service-booking. 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'; @@ -28,6 +29,7 @@ import { TelephonyService } from './telephony.service'; 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..250493d3 --- /dev/null +++ b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts @@ -0,0 +1,291 @@ +import { 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, + 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(); From a546ac0bd5b22836dc109efdd2b1fe941f3cb7ce Mon Sep 17 00:00:00 2001 From: Depeng Sun Date: Sat, 8 Nov 2025 19:04:25 +1030 Subject: [PATCH 7/9] Refactor Google Calendar token handling to return success message upon token creation/update. Update MCP calendar integration service to be asynchronous. Adjust subscription module imports for better clarity and inject Twilio service with forward reference. Enhance Twilio phone number assignment service with proper dependency injection. --- src/modules/google-calendar/calendar-token.controller.ts | 3 ++- .../services/mcp-calendar-integration.service.ts | 2 +- src/modules/subscription/subscription.module.ts | 2 +- src/modules/subscription/subscription.service.ts | 3 +++ .../twilio-phone-number-assignment.service.ts | 9 ++++++++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/modules/google-calendar/calendar-token.controller.ts b/src/modules/google-calendar/calendar-token.controller.ts index c8ef55da..b052092d 100644 --- a/src/modules/google-calendar/calendar-token.controller.ts +++ b/src/modules/google-calendar/calendar-token.controller.ts @@ -38,7 +38,8 @@ export class CalendarTokenController { async createOrUpdateToken( @Body() createDto: CreateCalendarTokenDto, ): Promise<{ message: string }> { - return await this.calendarTokenService.createOrUpdateToken(createDto); + await this.calendarTokenService.createOrUpdateToken(createDto); + return { message: 'Token created/updated successfully' }; } @ApiOperation({ summary: 'Get user calendar token' }) 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 9843834b..22fc7cab 100644 --- a/src/modules/google-calendar/services/mcp-calendar-integration.service.ts +++ b/src/modules/google-calendar/services/mcp-calendar-integration.service.ts @@ -265,7 +265,7 @@ Service: ${callData.serviceType} * Call MCP AI backend to create calendar event and send email. * TODO: Implement actual API call when MCP backend is ready */ - callMcpAiBackend( + async callMcpAiBackend( userId: string, mcpParams: { accessToken: string; diff --git a/src/modules/subscription/subscription.module.ts b/src/modules/subscription/subscription.module.ts index fbf898df..4c52a2a4 100644 --- a/src/modules/subscription/subscription.module.ts +++ b/src/modules/subscription/subscription.module.ts @@ -17,7 +17,7 @@ import { SubscriptionService } from './subscription.service'; { name: User.name, schema: userSchema }, ]), forwardRef(() => StripeModule), - TwilioPhoneNumberAssignmentModule, + forwardRef(() => TwilioPhoneNumberAssignmentModule), ], controllers: [SubscriptionController], providers: [SubscriptionService], diff --git a/src/modules/subscription/subscription.service.ts b/src/modules/subscription/subscription.service.ts index d6de0c3a..709e167c 100644 --- a/src/modules/subscription/subscription.service.ts +++ b/src/modules/subscription/subscription.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -32,6 +34,7 @@ export class SubscriptionService { @InjectModel(User.name) private readonly UserModel: Model, private readonly stripeService: StripeService, + @Inject(forwardRef(() => TwilioPhoneNumberAssignmentService)) private readonly twilioPhoneNumberAssignmentService: TwilioPhoneNumberAssignmentService, ) {} 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 index 250493d3..9a0d39cd 100644 --- 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 @@ -1,4 +1,10 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + NotFoundException, + forwardRef, +} from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Model, Types } from 'mongoose'; @@ -25,6 +31,7 @@ export class TwilioPhoneNumberAssignmentService { @InjectModel(Subscription.name) private readonly subscriptionModel: Model, private readonly twilioPhoneNumberService: TwilioPhoneNumberService, + @Inject(forwardRef(() => SubscriptionService)) private readonly subscriptionService: SubscriptionService, ) {} From db3197df900fa2b9527a4f961cbf042ec9967f3e Mon Sep 17 00:00:00 2001 From: Depeng Sun Date: Sat, 8 Nov 2025 19:09:34 +1030 Subject: [PATCH 8/9] Refactor MCP calendar integration service to streamline API call structure and improve logging. Update Twilio phone number assignment service to ensure proper dependency injection with forward reference. --- .../mcp-calendar-integration.service.ts | 64 ++++++++----------- .../twilio-phone-number-assignment.service.ts | 2 +- 2 files changed, 28 insertions(+), 38 deletions(-) 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 22fc7cab..67b607ed 100644 --- a/src/modules/google-calendar/services/mcp-calendar-integration.service.ts +++ b/src/modules/google-calendar/services/mcp-calendar-integration.service.ts @@ -263,9 +263,8 @@ Service: ${callData.serviceType} /** * Call MCP AI backend to create calendar event and send email. - * TODO: Implement actual API call when MCP backend is ready */ - async callMcpAiBackend( + callMcpAiBackend( userId: string, mcpParams: { accessToken: string; @@ -286,48 +285,39 @@ Service: ${callData.serviceType} location?: string; attendees?: string[]; }, - ): Promise<{ + ): { success: boolean; eventId: string; emailSent: boolean; message: string; timestamp: string; - }> { - try { - // Build MCP API request payload - const mcpRequest = { - ...mcpParams, - ...emailData, - ...calendarData, - timezone: 'Australia/Sydney', - alarm_minutes_before: 15, // 15-minute reminder before event - }; + } { + // 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/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts b/src/modules/twilio-phone-number-assignment/twilio-phone-number-assignment.service.ts index 9a0d39cd..7cdd3981 100644 --- 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 @@ -1,9 +1,9 @@ import { + forwardRef, Inject, Injectable, Logger, NotFoundException, - forwardRef, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Cron, CronExpression } from '@nestjs/schedule'; From d1c7ff27ac3e5132aede8cf969e8f9be93dc7b51 Mon Sep 17 00:00:00 2001 From: Depeng Sun Date: Sat, 8 Nov 2025 19:36:08 +1030 Subject: [PATCH 9/9] Add seed:plan command and update README for plan data seeding --- package.json | 3 +- scripts/seeds/README.md | 34 ++++++- scripts/seeds/index.ts | 3 +- scripts/seeds/seed-plan-data.ts | 175 ++++++++++++++++++++++++++++++++ scripts/seeds/seed-plan.ts | 19 ++++ 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 scripts/seeds/seed-plan-data.ts create mode 100644 scripts/seeds/seed-plan.ts diff --git a/package.json b/package.json index cf0995de..42981802 100644 --- a/package.json +++ b/package.json @@ -52,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/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); + }); +