mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
feat(email): abandoned checkout email, 80% free tier warning, credits exhausted email (#3908)
* feat(email): send plain personal email on abandoned checkout * feat(email): lower free tier warning to 80% and add credits exhausted email * feat(email): use wordmark in email header instead of icon-only logo * fix(email): restore accidentally deleted social icons in email footer * fix(email): prevent double email for free users at 80%, fix subject line * improvement(emails): extract shared plain email styles and proFeatures constant, fix double email on 100% usage * fix(email): filter subscription-mode checkout, skip already-subscribed users, fix preview text * fix(email): use notifications type for onboarding followup to respect unsubscribe preferences * fix(email): use limit instead of currentUsage in credits exhausted email body * fix(email): use notifications type for abandoned checkout, clarify crosses80 comment * chore(email): rename _constants.ts to constants.ts * fix(email): use isProPlan to catch org-level subscriptions in abandoned checkout guard * fix(email): align onboarding followup delay to 5 days for email/password users
This commit is contained in:
@@ -50,7 +50,7 @@ async function sendLifecycleEmail({ userId, type }: LifecycleEmailParams): Promi
|
||||
html,
|
||||
from,
|
||||
replyTo,
|
||||
emailType: 'transactional',
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
logger.info('[lifecycle-email] Sent lifecycle email', { userId, type })
|
||||
|
||||
@@ -267,3 +267,24 @@ export const baseStyles = {
|
||||
margin: '8px 0',
|
||||
},
|
||||
}
|
||||
|
||||
/** Styles for plain personal emails (no branding, no EmailLayout) */
|
||||
export const plainEmailStyles = {
|
||||
body: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
},
|
||||
container: {
|
||||
maxWidth: '560px',
|
||||
margin: '40px auto',
|
||||
padding: '0 24px',
|
||||
},
|
||||
p: {
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: '#1a1a1a',
|
||||
margin: '0 0 16px',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { baseStyles, colors, spacing, typography } from './base'
|
||||
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
|
||||
interface OnboardingFollowupEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
},
|
||||
container: {
|
||||
maxWidth: '560px',
|
||||
margin: '40px auto',
|
||||
padding: '0 24px',
|
||||
},
|
||||
p: {
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: '#1a1a1a',
|
||||
margin: '0 0 16px',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
|
||||
interface AbandonedCheckoutEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Did you run into an issue with your upgrade?</Preview>
|
||||
<Body style={styles.body}>
|
||||
<div style={styles.container}>
|
||||
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
<Text style={styles.p}>
|
||||
I saw that you tried to upgrade your Sim plan but didn't end up completing it.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
Did you run into an issue, or did you have a question? Here to help.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
— Emir
|
||||
<br />
|
||||
Founder, Sim
|
||||
</Text>
|
||||
</div>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbandonedCheckoutEmail
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Pro plan features shown in billing upgrade emails */
|
||||
export const proFeatures = [
|
||||
{ label: '6,000 credits/month', desc: 'included' },
|
||||
{ label: '+50 daily refresh', desc: 'credits per day' },
|
||||
{ label: '150 runs/min', desc: 'sync executions' },
|
||||
{ label: '50GB storage', desc: 'for files & assets' },
|
||||
] as const
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface CreditsExhaustedEmailProps {
|
||||
userName?: string
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}
|
||||
|
||||
export function CreditsExhaustedEmail({
|
||||
userName,
|
||||
limit,
|
||||
upgradeLink,
|
||||
}: CreditsExhaustedEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`}
|
||||
showUnsubscribe={true}
|
||||
>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> of your
|
||||
free credits on {brand.name}. Your workflows are paused until you upgrade.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: '#f8faf9',
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification when free credits are exhausted.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsExhaustedEmail
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
@@ -12,13 +13,6 @@ interface FreeTierUpgradeEmailProps {
|
||||
upgradeLink: string
|
||||
}
|
||||
|
||||
const proFeatures = [
|
||||
{ label: '6,000 credits/month', desc: 'included' },
|
||||
{ label: '+50 daily refresh', desc: 'credits per day' },
|
||||
{ label: '150 runs/min', desc: 'sync executions' },
|
||||
{ label: '50GB storage', desc: 'for files & assets' },
|
||||
]
|
||||
|
||||
export function FreeTierUpgradeEmail({
|
||||
userName,
|
||||
percentUsed,
|
||||
@@ -105,7 +99,7 @@ export function FreeTierUpgradeEmail({
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 90% usage.
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
|
||||
export { CreditPurchaseEmail } from './credit-purchase-email'
|
||||
export { CreditsExhaustedEmail } from './credits-exhausted-email'
|
||||
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
|
||||
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
|
||||
export { PaymentFailedEmail } from './payment-failed-email'
|
||||
|
||||
@@ -41,8 +41,9 @@ export function EmailLayout({
|
||||
{/* Header with logo */}
|
||||
<Section style={baseStyles.header}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/brand/color/email/type.png`}
|
||||
width='70'
|
||||
src={brand.logoUrl || `${baseUrl}/brand/color/email/wordmark.png`}
|
||||
width='107'
|
||||
height='33'
|
||||
alt={brand.name}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
WelcomeEmail,
|
||||
} from '@/components/emails/auth'
|
||||
import {
|
||||
AbandonedCheckoutEmail,
|
||||
CreditPurchaseEmail,
|
||||
CreditsExhaustedEmail,
|
||||
EnterpriseSubscriptionEmail,
|
||||
FreeTierUpgradeEmail,
|
||||
PaymentFailedEmail,
|
||||
@@ -168,6 +170,18 @@ export async function renderOnboardingFollowupEmail(userName?: string): Promise<
|
||||
return await render(OnboardingFollowupEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
|
||||
return await render(AbandonedCheckoutEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderCreditsExhaustedEmail(params: {
|
||||
userName?: string
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}): Promise<string> {
|
||||
return await render(CreditsExhaustedEmail(params))
|
||||
}
|
||||
|
||||
export async function renderCreditPurchaseEmail(params: {
|
||||
userName?: string
|
||||
amount: number
|
||||
|
||||
@@ -16,6 +16,8 @@ export type EmailSubjectType =
|
||||
| 'plan-welcome-pro'
|
||||
| 'plan-welcome-team'
|
||||
| 'credit-purchase'
|
||||
| 'abandoned-checkout'
|
||||
| 'free-tier-exhausted'
|
||||
| 'onboarding-followup'
|
||||
| 'welcome'
|
||||
|
||||
@@ -49,13 +51,17 @@ export function getEmailSubject(type: EmailSubjectType): string {
|
||||
case 'usage-threshold':
|
||||
return `You're nearing your monthly budget on ${brandName}`
|
||||
case 'free-tier-upgrade':
|
||||
return `You're at 90% of your free credits on ${brandName}`
|
||||
return `You're at 80% of your free credits on ${brandName}`
|
||||
case 'plan-welcome-pro':
|
||||
return `Your Pro plan is now active on ${brandName}`
|
||||
case 'plan-welcome-team':
|
||||
return `Your Team plan is now active on ${brandName}`
|
||||
case 'credit-purchase':
|
||||
return `Credits added to your ${brandName} account`
|
||||
case 'abandoned-checkout':
|
||||
return `Quick question`
|
||||
case 'free-tier-exhausted':
|
||||
return `You've run out of free credits on ${brandName}`
|
||||
case 'onboarding-followup':
|
||||
return `Quick question about ${brandName}`
|
||||
case 'welcome':
|
||||
|
||||
@@ -47,6 +47,7 @@ import { isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
|
||||
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
|
||||
import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
|
||||
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
|
||||
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
|
||||
import {
|
||||
@@ -615,7 +616,7 @@ export const auth = betterAuth({
|
||||
await scheduleLifecycleEmail({
|
||||
userId: user.id,
|
||||
type: 'onboarding-followup',
|
||||
delayDays: 3,
|
||||
delayDays: 5,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -2981,6 +2982,10 @@ export const auth = betterAuth({
|
||||
await handleManualEnterpriseSubscription(event)
|
||||
break
|
||||
}
|
||||
case 'checkout.session.expired': {
|
||||
await handleAbandonedCheckout(event)
|
||||
break
|
||||
}
|
||||
case 'charge.dispute.created': {
|
||||
await handleChargeDispute(event)
|
||||
break
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderCreditsExhaustedEmail,
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderUsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
@@ -714,16 +715,16 @@ export async function maybeSendUsageThresholdEmail(params: {
|
||||
const baseUrl = getBaseUrl()
|
||||
const isFreeUser = params.planName === 'Free'
|
||||
|
||||
// Check for 80% threshold (all users)
|
||||
// Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge)
|
||||
const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80
|
||||
// Check for 90% threshold (free users only)
|
||||
const crosses90 = params.percentBefore < 90 && params.percentAfter >= 90
|
||||
// Check for 100% threshold (free users only — credits exhausted)
|
||||
const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100
|
||||
|
||||
// Skip if no thresholds crossed
|
||||
if (!crosses80 && !crosses90) return
|
||||
if (!crosses80 && !crosses100) return
|
||||
|
||||
// For 80% threshold email (all users)
|
||||
if (crosses80) {
|
||||
// For 80% threshold email (paid users only)
|
||||
if (crosses80 && !isFreeUser) {
|
||||
const ctaLink = `${baseUrl}/workspace?billing=usage`
|
||||
const sendTo = async (email: string, name?: string) => {
|
||||
const prefs = await getEmailPreferences(email)
|
||||
@@ -777,8 +778,8 @@ export async function maybeSendUsageThresholdEmail(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// For 90% threshold email (free users only)
|
||||
if (crosses90 && isFreeUser) {
|
||||
// For 80% threshold email (free users only — skip if they also crossed 100% in same call)
|
||||
if (crosses80 && isFreeUser && !crosses100) {
|
||||
const upgradeLink = `${baseUrl}/workspace?billing=upgrade`
|
||||
const sendFreeTierEmail = async (email: string, name?: string) => {
|
||||
const prefs = await getEmailPreferences(email)
|
||||
@@ -818,6 +819,44 @@ export async function maybeSendUsageThresholdEmail(params: {
|
||||
await sendFreeTierEmail(params.userEmail, params.userName)
|
||||
}
|
||||
}
|
||||
|
||||
// For 100% threshold email (free users only — credits exhausted)
|
||||
if (crosses100 && isFreeUser) {
|
||||
const upgradeLink = `${baseUrl}/workspace?billing=upgrade`
|
||||
const sendExhaustedEmail = async (email: string, name?: string) => {
|
||||
const prefs = await getEmailPreferences(email)
|
||||
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
|
||||
|
||||
const html = await renderCreditsExhaustedEmail({
|
||||
userName: name,
|
||||
limit: params.limit,
|
||||
upgradeLink,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject('free-tier-exhausted'),
|
||||
html,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
logger.info('Free tier credits exhausted email sent', {
|
||||
email,
|
||||
currentUsage: params.currentUsageAfter,
|
||||
limit: params.limit,
|
||||
})
|
||||
}
|
||||
|
||||
if (params.scope === 'user' && params.userId && params.userEmail) {
|
||||
const rows = await db
|
||||
.select({ enabled: settings.billingUsageNotificationsEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, params.userId))
|
||||
.limit(1)
|
||||
if (rows.length > 0 && rows[0].enabled === false) return
|
||||
await sendExhaustedEmail(params.userEmail, params.userName)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send usage threshold email', {
|
||||
scope: params.scope,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails'
|
||||
import { isProPlan } from '@/lib/billing/core/subscription'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
|
||||
const logger = createLogger('CheckoutWebhooks')
|
||||
|
||||
/**
|
||||
* Handles checkout.session.expired — fires when a user starts an upgrade but doesn't complete it.
|
||||
* Sends a plain personal email to check in and offer help.
|
||||
* Only fires for subscription-mode sessions to avoid misfires on credit purchase or setup sessions.
|
||||
* Skips users who have already completed a subscription (session may expire after a successful upgrade).
|
||||
*/
|
||||
export async function handleAbandonedCheckout(event: Stripe.Event): Promise<void> {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
|
||||
if (session.mode !== 'subscription') return
|
||||
|
||||
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
|
||||
if (!customerId) {
|
||||
logger.warn('No customer ID on expired session', { sessionId: session.id })
|
||||
return
|
||||
}
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.stripeCustomerId, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData?.email) {
|
||||
logger.warn('No user found for Stripe customer', { customerId, sessionId: session.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if the user already has a paid plan (direct or via org) — covers session expiring after a successful upgrade
|
||||
const alreadySubscribed = await isProPlan(userData.id)
|
||||
if (alreadySubscribed) return
|
||||
|
||||
const { from, replyTo } = getPersonalEmailFrom()
|
||||
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
|
||||
|
||||
await sendEmail({
|
||||
to: userData.email,
|
||||
subject: getEmailSubject('abandoned-checkout'),
|
||||
html,
|
||||
from,
|
||||
replyTo,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
logger.info('Sent abandoned checkout email', { userId: userData.id, sessionId: session.id })
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 346 B |
Binary file not shown.
|
After Width: | Height: | Size: 394 B |
Binary file not shown.
|
After Width: | Height: | Size: 407 B |
Reference in New Issue
Block a user