Browse Source

feat(api): send checkout reminder emails

pull/125/head
isra el 6 months ago
parent
commit
3f838ce253
  1. 258
      api/src/billing/abandoned-checkout.service.ts
  2. 7
      api/src/billing/billing.module.ts
  3. 26
      api/src/billing/schemas/checkout-session.schema.ts
  4. 270
      api/src/mail/templates/abandoned-checkout-10-minutes.hbs

258
api/src/billing/abandoned-checkout.service.ts

@ -0,0 +1,258 @@
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
hoursAfterExpiry: number
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!',
hoursAfterExpiry: -0.167, // 10 minutes before expiry (-10/60 hours)
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) {
let windowStart: Date, windowEnd: Date
let query: any
if (emailConfig.hoursAfterExpiry < 0) {
// Before expiry: find sessions that will expire soon
const targetTime = new Date()
targetTime.setHours(
targetTime.getHours() + Math.abs(emailConfig.hoursAfterExpiry),
)
windowStart = new Date(targetTime.getTime() - 60 * 60 * 1000) // 1 hour window
windowEnd = new Date(targetTime.getTime() + 60 * 60 * 1000)
query = {
expiresAt: {
$gte: windowStart,
$lte: windowEnd,
$gt: new Date(), // Only send to sessions that haven't expired yet
},
'abandonedEmails.emailType': { $ne: emailConfig.emailType },
}
} else {
// After expiry: find sessions that expired the specified time ago
const targetTime = new Date()
targetTime.setHours(targetTime.getHours() - emailConfig.hoursAfterExpiry)
windowStart = new Date(targetTime.getTime() - 60 * 60 * 1000)
windowEnd = new Date(targetTime.getTime() + 60 * 60 * 1000)
query = {
expiresAt: {
$gte: windowStart,
$lte: windowEnd,
},
'abandonedEmails.emailType': { $ne: emailConfig.emailType },
}
}
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()
}
}

7
api/src/billing/billing.module.ts

@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common' import { Module, forwardRef } from '@nestjs/common'
import { BillingController } from './billing.controller' import { BillingController } from './billing.controller'
import { BillingService } from './billing.service' import { BillingService } from './billing.service'
import { AbandonedCheckoutService } from './abandoned-checkout.service'
import { PlanSchema } from './schemas/plan.schema' import { PlanSchema } from './schemas/plan.schema'
import { SubscriptionSchema } from './schemas/subscription.schema' import { SubscriptionSchema } from './schemas/subscription.schema'
import { Plan } from './schemas/plan.schema' import { Plan } from './schemas/plan.schema'
@ -9,6 +10,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from 'src/auth/auth.module' import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module' import { UsersModule } from 'src/users/users.module'
import { GatewayModule } from 'src/gateway/gateway.module' import { GatewayModule } from 'src/gateway/gateway.module'
import { MailModule } from 'src/mail/mail.module'
import { PolarWebhookPayload, PolarWebhookPayloadSchema } from './schemas/polar-webhook-payload.schema' import { PolarWebhookPayload, PolarWebhookPayloadSchema } from './schemas/polar-webhook-payload.schema'
import { Device, DeviceSchema } from '../gateway/schemas/device.schema' import { Device, DeviceSchema } from '../gateway/schemas/device.schema'
import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-session.schema' import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-session.schema'
@ -25,9 +27,10 @@ import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-sessi
forwardRef(() => AuthModule), forwardRef(() => AuthModule),
forwardRef(() => UsersModule), forwardRef(() => UsersModule),
forwardRef(() => GatewayModule), forwardRef(() => GatewayModule),
MailModule,
], ],
controllers: [BillingController], controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
providers: [BillingService, AbandonedCheckoutService],
exports: [BillingService, AbandonedCheckoutService],
}) })
export class BillingModule {} export class BillingModule {}

26
api/src/billing/schemas/checkout-session.schema.ts

@ -4,6 +4,12 @@ import { User } from '../../users/schemas/user.schema'
export type CheckoutSessionDocument = CheckoutSession & Document export type CheckoutSessionDocument = CheckoutSession & Document
export interface AbandonedEmailRecord {
emailType: 'first_reminder' | 'second_reminder' | 'third_reminder' | 'final_reminder' | 'last_chance'
sentAt: Date
emailSubject: string
}
@Schema({ timestamps: true }) @Schema({ timestamps: true })
export class CheckoutSession { export class CheckoutSession {
_id?: Types.ObjectId _id?: Types.ObjectId
@ -22,6 +28,26 @@ export class CheckoutSession {
@Prop({ type: Object, required: true, default: {} }) @Prop({ type: Object, required: true, default: {} })
payload: any payload: any
// Abandoned checkout email tracking
@Prop({
type: [{
emailType: { type: String, enum: ['first_reminder', 'second_reminder', 'third_reminder', 'final_reminder', 'last_chance'] },
sentAt: { type: Date },
emailSubject: { type: String }
}],
default: []
})
abandonedEmails: AbandonedEmailRecord[]
@Prop({ type: Boolean, default: false })
isCompleted: boolean
@Prop({ type: Boolean, default: false })
isAbandoned: boolean
@Prop({ type: Date })
completedAt?: Date
} }
export const CheckoutSessionSchema = SchemaFactory.createForClass(CheckoutSession) export const CheckoutSessionSchema = SchemaFactory.createForClass(CheckoutSession)

270
api/src/mail/templates/abandoned-checkout-10-minutes.hbs

@ -0,0 +1,270 @@
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>Complete Your TextBee Upgrade</title>
<style>
.preheader { display:none !important; visibility:hidden; opacity:0;
color:transparent; height:0; width:0; overflow:hidden; mso-hide:all; }
@media screen and (max-width: 600px) { .container { width:100% !important;
} .stack { display:block !important; width:100% !important; } .p-sm {
padding:16px !important; } .text-center-sm { text-align:center !important;
} }
</style>
</head>
<body style='margin:0; padding:0; background:#f7f9fc;'>
<div class='preheader'>Your textbee pro upgrade is waiting - complete your
payment in just few clicks.</div>
<table
role='presentation'
cellpadding='0'
cellspacing='0'
border='0'
width='100%'
style='background:#f7f9fc;'
>
<tr>
<td align='center' style='padding:24px;'>
<table
role='presentation'
cellpadding='0'
cellspacing='0'
border='0'
width='600'
class='container'
style='width:600px; max-width:600px;'
>
<!-- Header -->
<tr>
<td style='padding:12px 16px 0 16px;'>
<table
role='presentation'
width='100%'
cellspacing='0'
cellpadding='0'
border='0'
>
<tr>
<td class='stack' valign='middle' style='padding:8px 0;'>
<table
role='presentation'
cellspacing='0'
cellpadding='0'
border='0'
>
<tr>
<td valign='middle' style='padding-right:10px;'>
<img
src='https://textbee.dev/images/logo.png'
alt='TextBee'
width='36'
height='36'
style='display:block; border:0; outline:none; text-decoration:none;'
/>
</td>
<td
valign='middle'
style='font:600 18px Arial, Helvetica, sans-serif; color:#EA580C;'
>textbee.dev</td>
</tr>
</table>
</td>
<td
class='stack text-center-sm'
valign='middle'
align='right'
style='padding:8px 0;'
>
<a
href='https://textbee.dev/x'
style='margin-left:10px; display:inline-block;'
>
<img
src='https://textbee.dev/images/socials/twitter.png'
alt='X'
width='20'
height='20'
style='display:block; border:0;'
/>
</a>
<a
href='https://textbee.dev/linkedin'
style='margin-left:10px; display:inline-block;'
>
<img
src='https://textbee.dev/images/socials/linkedin.png'
alt='LinkedIn'
width='20'
height='20'
style='display:block; border:0;'
/>
</a>
<a
href='https://textbee.dev/github'
style='margin-left:10px; display:inline-block;'
>
<img
src='https://textbee.dev/images/socials/github.png'
alt='GitHub'
width='20'
height='20'
style='display:block; border:0;'
/>
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Content -->
<tr>
<td align='center' style='padding:16px 16px 0 16px;'>
<table
role='presentation'
width='100%'
cellspacing='0'
cellpadding='0'
border='0'
style='background:#ffffff; border-radius:10px;'
>
<!-- Hero -->
<tr>
<td
align='center'
style='background:#F97316; border-radius:10px 10px 0 0; padding:28px 20px;'
>
<div
style='font:700 24px Arial, Helvetica, sans-serif; color:#ffffff;'
>Don't Miss Out!</div>
<div
style='font:400 14px Arial, Helvetica, sans-serif; color:#fff; opacity:0.95; padding-top:6px;'
>Your textbee pro upgrade is waiting</div>
</td>
</tr>
<!-- Body -->
<tr>
<td
class='p-sm'
style='padding:28px; font:16px/1.6 Arial, Helvetica, sans-serif; color:#111827;'
>
<div
style='font:700 18px Arial, Helvetica, sans-serif; color:#111827; margin-bottom:8px;'
>Hi {{name}},</div>
<p style='margin:0 0 16px 0;'>We noticed you started
upgrading to textbee pro but didn't complete your
purchase. Your upgrade is still available and ready to
unlock powerful features for your SMS workflow.</p>
<!-- Urgency Box -->
<table
role='presentation'
cellpadding='0'
cellspacing='0'
border='0'
width='100%'
style='margin:18px 0;'
>
<tr>
<td
style='padding:16px; background:#fef3c7; border-left:4px solid #f59e0b; border-radius:6px;'
>
<div
style='font:600 15px Arial, Helvetica, sans-serif; color:#92400e; margin-bottom:4px;'
>A 30% discount is waiting for you</div>
<div
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#78350f;'
>Complete your upgrade now and save on your textbee
pro subscription.</div>
</td>
</tr>
</table>
<!-- Primary CTA -->
<div style='text-align:center; padding:8px 0 20px;'>
<a
href='{{checkoutUrl}}'
style='background:#F97316; border:1px solid #EA580C; border-radius:6px; color:#ffffff; display:inline-block; font:700 16px Arial, Helvetica, sans-serif; line-height:48px; text-align:center; text-decoration:none; width:280px;'
>Complete Your Upgrade</a>
</div>
<!-- Benefits Reminder -->
<div
style='font:700 16px Arial, Helvetica, sans-serif; margin:20px 0 8px;'
>What you'll get with Pro:</div>
<ul
style='padding-left:18px; margin:8px 0 20px; font:15px/1.6 Arial, Helvetica, sans-serif; color:#374151;'
>
<li style='margin:6px 0;'>5,000 SMS messages (send &
receive) per month</li>
<li style='margin:6px 0;'>Connect up to 5 devices</li>
<li style='margin:6px 0;'>Higher rate limits for bulk
messaging</li>
<li style='margin:6px 0;'>Advanced analytics & reporting
(launching soon)</li>
<li style='margin:6px 0;'>Priority support and more</li>
</ul>
<!-- Secondary CTA -->
<div style='text-align:center; margin:16px 0;'>
<a
href='https://textbee.dev/#pricing'
style='font:600 14px Arial, Helvetica, sans-serif; color:#EA580C; text-decoration:underline;'
>View pricing details →</a>
</div>
<!-- Support -->
<table
role='presentation'
cellpadding='0'
cellspacing='0'
border='0'
width='100%'
style='margin:20px 0; padding:16px; background:#f8f9fa; border-radius:8px;'
>
<tr>
<td
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#6b7280; text-align:center;'
>
Questions? Reply to this email or contact us at
<a
href='mailto:support@textbee.dev'
style='color:#EA580C; text-decoration:underline;'
>support@textbee.dev</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td
align='center'
style='padding:16px; font:12px/1.6 Arial, Helvetica, sans-serif; color:#6b7280;'
>
<div>© 2025 textbee.dev. All rights reserved.</div>
<div style='margin-top:4px;'>
<a
href='https://app.textbee.dev/dashboard/account/'
style='color:#6b7280; text-decoration:underline;'
>Manage notifications</a>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Loading…
Cancel
Save