4 changed files with 559 additions and 2 deletions
-
258api/src/billing/abandoned-checkout.service.ts
-
7api/src/billing/billing.module.ts
-
26api/src/billing/schemas/checkout-session.schema.ts
-
270api/src/mail/templates/abandoned-checkout-10-minutes.hbs
@ -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() |
|||
} |
|||
} |
|||
@ -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> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue