Skip to content

Commit ffec9e9

Browse files
committed
feat(email): send plain personal email on abandoned checkout
1 parent b0c0ee2 commit ffec9e9

File tree

6 files changed

+123
-0
lines changed

6 files changed

+123
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Body, Head, Html, Preview, Text } from '@react-email/components'
2+
3+
interface AbandonedCheckoutEmailProps {
4+
userName?: string
5+
}
6+
7+
const styles = {
8+
body: {
9+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
10+
backgroundColor: '#ffffff',
11+
margin: '0',
12+
padding: '0',
13+
},
14+
container: {
15+
maxWidth: '560px',
16+
margin: '40px auto',
17+
padding: '0 24px',
18+
},
19+
p: {
20+
fontSize: '15px',
21+
lineHeight: '1.6',
22+
color: '#1a1a1a',
23+
margin: '0 0 16px',
24+
},
25+
} as const
26+
27+
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
28+
return (
29+
<Html>
30+
<Head />
31+
<Preview>Quick question</Preview>
32+
<Body style={styles.body}>
33+
<div style={styles.container}>
34+
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
35+
<Text style={styles.p}>
36+
I saw that you tried to upgrade your Sim plan but didn&apos;t end up completing it.
37+
</Text>
38+
<Text style={styles.p}>
39+
Did you run into an issue, or did you have a question? Here to help.
40+
</Text>
41+
<Text style={styles.p}>
42+
— Emir
43+
<br />
44+
Founder, Sim
45+
</Text>
46+
</div>
47+
</Body>
48+
</Html>
49+
)
50+
}
51+
52+
export default AbandonedCheckoutEmail

apps/sim/components/emails/billing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
12
export { CreditPurchaseEmail } from './credit-purchase-email'
23
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
34
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'

apps/sim/components/emails/render.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
WelcomeEmail,
77
} from '@/components/emails/auth'
88
import {
9+
AbandonedCheckoutEmail,
910
CreditPurchaseEmail,
1011
EnterpriseSubscriptionEmail,
1112
FreeTierUpgradeEmail,
@@ -168,6 +169,10 @@ export async function renderOnboardingFollowupEmail(userName?: string): Promise<
168169
return await render(OnboardingFollowupEmail({ userName }))
169170
}
170171

172+
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
173+
return await render(AbandonedCheckoutEmail({ userName }))
174+
}
175+
171176
export async function renderCreditPurchaseEmail(params: {
172177
userName?: string
173178
amount: number

apps/sim/components/emails/subjects.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type EmailSubjectType =
1616
| 'plan-welcome-pro'
1717
| 'plan-welcome-team'
1818
| 'credit-purchase'
19+
| 'abandoned-checkout'
1920
| 'onboarding-followup'
2021
| 'welcome'
2122

@@ -56,6 +57,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
5657
return `Your Team plan is now active on ${brandName}`
5758
case 'credit-purchase':
5859
return `Credits added to your ${brandName} account`
60+
case 'abandoned-checkout':
61+
return `Quick question`
5962
case 'onboarding-followup':
6063
return `Quick question about ${brandName}`
6164
case 'welcome':

apps/sim/lib/auth/auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
4747
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
4848
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
4949
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
50+
import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
5051
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
5152
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
5253
import {
@@ -2981,6 +2982,10 @@ export const auth = betterAuth({
29812982
await handleManualEnterpriseSubscription(event)
29822983
break
29832984
}
2985+
case 'checkout.session.expired': {
2986+
await handleAbandonedCheckout(event)
2987+
break
2988+
}
29842989
case 'charge.dispute.created': {
29852990
await handleChargeDispute(event)
29862991
break
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { db } from '@sim/db'
2+
import { user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
5+
import type Stripe from 'stripe'
6+
import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails'
7+
import { sendEmail } from '@/lib/messaging/email/mailer'
8+
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
9+
10+
const logger = createLogger('CheckoutWebhooks')
11+
12+
/**
13+
* Handles checkout.session.expired — fires when a user starts an upgrade but doesn't complete it.
14+
* Sends a plain personal email to check in and offer help.
15+
*/
16+
export async function handleAbandonedCheckout(event: Stripe.Event): Promise<void> {
17+
const session = event.data.object as Stripe.Checkout.Session
18+
19+
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
20+
if (!customerId) {
21+
logger.warn('[handleAbandonedCheckout] No customer ID on expired session', {
22+
sessionId: session.id,
23+
})
24+
return
25+
}
26+
27+
const [userData] = await db
28+
.select({ id: user.id, email: user.email, name: user.name })
29+
.from(user)
30+
.where(eq(user.stripeCustomerId, customerId))
31+
.limit(1)
32+
33+
if (!userData?.email) {
34+
logger.warn('[handleAbandonedCheckout] No user found for Stripe customer', {
35+
customerId,
36+
sessionId: session.id,
37+
})
38+
return
39+
}
40+
41+
const { from, replyTo } = getPersonalEmailFrom()
42+
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
43+
44+
await sendEmail({
45+
to: userData.email,
46+
subject: getEmailSubject('abandoned-checkout'),
47+
html,
48+
from,
49+
replyTo,
50+
emailType: 'transactional',
51+
})
52+
53+
logger.info('[handleAbandonedCheckout] Sent abandoned checkout email', {
54+
userId: userData.id,
55+
sessionId: session.id,
56+
})
57+
}

0 commit comments

Comments
 (0)