You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

259 lines
7.6 KiB

import { Injectable, Logger } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import {
CheckoutSession,
CheckoutSessionDocument,
} from './schemas/checkout-session.schema'
import { User, UserDocument } from '../users/schemas/user.schema'
import { MailService } from '../mail/mail.service'
import { BillingService } from './billing.service'
interface EmailConfig {
template: string
subject: string
minutesBeforeExpiry?: number // Send X minutes before expiry
minutesAfterExpiry?: number // Send X minutes after expiry
emailType:
| 'first_reminder'
| 'second_reminder'
| 'third_reminder'
| 'final_reminder'
| 'last_chance'
}
@Injectable()
export class AbandonedCheckoutService {
private readonly logger = new Logger(AbandonedCheckoutService.name)
private readonly emailSchedule: EmailConfig[] = [
{
template: 'abandoned-checkout-10-minutes',
subject: '⏰ Your textbee pro upgrade is waiting!',
minutesBeforeExpiry: 15,
emailType: 'first_reminder',
},
]
constructor(
@InjectModel(CheckoutSession.name)
private checkoutSessionModel: Model<CheckoutSessionDocument>,
@InjectModel(User.name)
private userModel: Model<UserDocument>,
private mailService: MailService,
private billingService: BillingService,
) {}
@Cron(CronExpression.EVERY_10_MINUTES)
async processAbandonedCheckouts() {
this.logger.log('Starting abandoned checkout processing...')
try {
for (const emailConfig of this.emailSchedule) {
await this.sendReminderEmails(emailConfig)
}
this.logger.log('Abandoned checkout processing completed successfully')
} catch (error) {
this.logger.error('Error processing abandoned checkouts:', error)
}
}
/**
* Send reminder emails for a specific email configuration
*/
private async sendReminderEmails(emailConfig: EmailConfig) {
const now = new Date()
let windowStart: Date, windowEnd: Date
let query: any
if (emailConfig.minutesBeforeExpiry !== undefined) {
// BEFORE EXPIRY: Find sessions that will expire in X minutes
const targetExpiryTime = new Date(now.getTime() + emailConfig.minutesBeforeExpiry * 60 * 1000)
windowStart = new Date(targetExpiryTime.getTime() - 10 * 60 * 1000)
windowEnd = new Date(targetExpiryTime.getTime() + 10 * 60 * 1000)
query = {
expiresAt: {
$gte: windowStart,
$lte: windowEnd,
},
'abandonedEmails.emailType': { $ne: emailConfig.emailType }, // Don't send duplicate emails
}
} else if (emailConfig.minutesAfterExpiry !== undefined) {
// AFTER EXPIRY: Find sessions that expired X minutes ago
const targetExpiryTime = new Date(now.getTime() - emailConfig.minutesAfterExpiry * 60 * 1000)
windowStart = new Date(targetExpiryTime.getTime() - 10 * 60 * 1000)
windowEnd = new Date(targetExpiryTime.getTime() + 10 * 60 * 1000)
query = {
expiresAt: {
$gte: windowStart,
$lte: windowEnd,
},
'abandonedEmails.emailType': { $ne: emailConfig.emailType }, // Don't send duplicate emails
}
} else {
this.logger.error(`Invalid email config: must specify either minutesBeforeExpiry or minutesAfterExpiry`)
return
}
const abandonedSessions = await this.checkoutSessionModel
.find(query)
.populate('user')
.limit(100)
if (abandonedSessions.length === 0) {
this.logger.debug(
`No abandoned checkouts found for ${emailConfig.emailType}`,
)
return
}
this.logger.log(
`Found ${abandonedSessions.length} abandoned checkouts for ${emailConfig.emailType}`,
)
for (const session of abandonedSessions) {
try {
// check if user is already on pro plan
const subscription = await this.billingService.getCurrentSubscription(
session.user,
)
if (subscription.plan.name !== 'free') {
this.logger.debug(
`Skipping email for session ${session._id}: user is not on free plan`,
)
continue
}
await this.sendAbandonedCheckoutEmail(session, emailConfig)
await new Promise((resolve) => setTimeout(resolve, 100))
} catch (error) {
this.logger.error(
`Failed to send ${emailConfig.emailType} email for session ${session._id}:`,
error,
)
}
}
}
/**
* Send an individual abandoned checkout email
*/
private async sendAbandonedCheckoutEmail(
session: CheckoutSessionDocument,
emailConfig: EmailConfig,
) {
const user = session.user as UserDocument
// Skip if user has opted out of marketing emails
// if (user.marketingEmailsOptOut) {
// this.logger.debug(`Skipping email for session ${session._id}: user opted out of marketing emails`)
// return
// }
try {
const emailContext = {
name: user.name?.split(' ')?.[0] || 'there',
email: user.email,
checkoutUrl:
'https://app.textbee.dev/checkout/pro' /*session.checkoutUrl*/,
planName: this.extractPlanNameFromPayload(session.payload),
expiresAt: session.expiresAt,
}
await this.mailService.sendEmailFromTemplate({
to: user.email,
from: 'support@textbee.dev',
subject: emailConfig.subject,
template: emailConfig.template,
context: emailContext,
})
await this.checkoutSessionModel.updateOne(
{ _id: session._id },
{
$push: {
abandonedEmails: {
emailType: emailConfig.emailType,
sentAt: new Date(),
emailSubject: emailConfig.subject,
},
},
},
)
this.logger.log(
`Sent ${emailConfig.emailType} email to ${user.email} for session ${session._id}`,
)
} catch (error) {
this.logger.error(`Failed to send abandoned checkout email:`, error)
throw error
}
}
private extractPlanNameFromPayload(payload: any): string {
if (payload?.products?.[0]?.name) {
return payload.products[0].name
}
if (payload?.product?.name) {
return payload.product.name
}
if (payload?.planName) {
return payload.planName
}
return 'Pro' // Default fallback
}
async markCheckoutCompleted(checkoutSessionId: string) {
try {
const result = await this.checkoutSessionModel.updateOne(
{ checkoutSessionId },
{
isCompleted: true,
completedAt: new Date(),
},
)
if (result.matchedCount > 0) {
this.logger.log(
`Marked checkout session ${checkoutSessionId} as completed`,
)
}
} catch (error) {
this.logger.error(`Failed to mark checkout session as completed:`, error)
}
}
/**
* Mark a checkout session as abandoned (optional - for analytics)
*/
async markCheckoutAbandoned(checkoutSessionId: string) {
try {
const result = await this.checkoutSessionModel.updateOne(
{ checkoutSessionId },
{
isAbandoned: true,
},
)
if (result.matchedCount > 0) {
this.logger.log(
`Marked checkout session ${checkoutSessionId} as abandoned`,
)
}
} catch (error) {
this.logger.error(`Failed to mark checkout session as abandoned:`, error)
}
}
async triggerAbandonedCheckoutProcess() {
this.logger.log('Manually triggering abandoned checkout process...')
await this.processAbandonedCheckouts()
}
}