committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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