mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
feat(notifications): added notifications for usage thresholds, overages, and welcome emails (#1266)
* feat(notifications): added notifications for usage thresholds, overages, and welcome emails * cleanup * updated logo, ack PR comments * ran migrations
This commit is contained in:
@@ -24,6 +24,7 @@ const SettingsSchema = z.object({
|
||||
unsubscribeNotifications: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
billingUsageNotificationsEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// Default settings values
|
||||
@@ -35,6 +36,7 @@ const defaultSettings = {
|
||||
consoleExpandedByDefault: true,
|
||||
telemetryEnabled: true,
|
||||
emailPreferences: {},
|
||||
billingUsageNotificationsEnabled: true,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -68,6 +70,7 @@ export async function GET() {
|
||||
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
|
||||
telemetryEnabled: userSettings.telemetryEnabled,
|
||||
emailPreferences: userSettings.emailPreferences ?? {},
|
||||
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { Skeleton, Switch } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -500,6 +499,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing usage notifications toggle */}
|
||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -527,3 +529,42 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingUsageNotificationsToggle() {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const load = async () => {
|
||||
const res = await fetch('/api/users/me/settings')
|
||||
const json = await res.json()
|
||||
const current = json?.data?.billingUsageNotificationsEnabled
|
||||
if (isMounted) setEnabled(current !== false)
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const update = async (next: boolean) => {
|
||||
setEnabled(next)
|
||||
await fetch('/api/users/me/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ billingUsageNotificationsEnabled: next }),
|
||||
})
|
||||
}
|
||||
|
||||
if (enabled === null) return null
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium text-sm'>Usage notifications</span>
|
||||
<span className='text-muted-foreground text-xs'>Email me when I reach 80% usage</span>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={(v: boolean) => update(v)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from '@react-email/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -80,7 +79,7 @@ export const BatchInvitationEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -45,7 +44,7 @@ export const EnterpriseSubscriptionEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
@@ -94,7 +93,7 @@ export const EnterpriseSubscriptionEmail = ({
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to Sim Enterprise!
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -60,7 +59,7 @@ export const HelpConfirmationEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
@@ -5,6 +5,8 @@ export { default as EmailFooter } from './footer'
|
||||
export { HelpConfirmationEmail } from './help-confirmation-email'
|
||||
export { InvitationEmail } from './invitation-email'
|
||||
export { OTPVerificationEmail } from './otp-verification-email'
|
||||
export { PlanWelcomeEmail } from './plan-welcome-email'
|
||||
export * from './render-email'
|
||||
export { ResetPasswordEmail } from './reset-password-email'
|
||||
export { UsageThresholdEmail } from './usage-threshold-email'
|
||||
export { WorkspaceInvitationEmail } from './workspace-invitation'
|
||||
|
||||
@@ -15,7 +15,6 @@ import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -66,7 +65,7 @@ export const InvitationEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@react-email/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -72,7 +71,7 @@ export const OTPVerificationEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal file
113
apps/sim/components/emails/plan-welcome-email.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { baseStyles } from './base-styles'
|
||||
|
||||
interface PlanWelcomeEmailProps {
|
||||
planName: 'Pro' | 'Team'
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
createdDate?: Date
|
||||
}
|
||||
|
||||
export function PlanWelcomeEmail({
|
||||
planName,
|
||||
userName,
|
||||
loginLink,
|
||||
createdDate = new Date(),
|
||||
}: PlanWelcomeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
const cta = loginLink || `${baseUrl}/login`
|
||||
|
||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
|
||||
build, test, and scale your agentic workflows.
|
||||
</Text>
|
||||
|
||||
<Link href={cta} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want to discuss your plan or get personalized help getting started?{' '}
|
||||
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
|
||||
Schedule a 15-minute call
|
||||
</Link>{' '}
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
|
||||
Settings → Subscription.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {createdDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanWelcomeEmail
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
HelpConfirmationEmail,
|
||||
InvitationEmail,
|
||||
OTPVerificationEmail,
|
||||
PlanWelcomeEmail,
|
||||
ResetPasswordEmail,
|
||||
UsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
@@ -100,6 +102,27 @@ export async function renderEnterpriseSubscriptionEmail(
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderUsageThresholdEmail(params: {
|
||||
userName?: string
|
||||
planName: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
ctaLink: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
UsageThresholdEmail({
|
||||
userName: params.userName,
|
||||
planName: params.planName,
|
||||
percentUsed: params.percentUsed,
|
||||
currentUsage: params.currentUsage,
|
||||
limit: params.limit,
|
||||
ctaLink: params.ctaLink,
|
||||
updatedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getEmailSubject(
|
||||
type:
|
||||
| 'sign-in'
|
||||
@@ -110,6 +133,9 @@ export function getEmailSubject(
|
||||
| 'batch-invitation'
|
||||
| 'help-confirmation'
|
||||
| 'enterprise-subscription'
|
||||
| 'usage-threshold'
|
||||
| 'plan-welcome-pro'
|
||||
| 'plan-welcome-team'
|
||||
): string {
|
||||
const brandName = getBrandConfig().name
|
||||
|
||||
@@ -130,7 +156,28 @@ export function getEmailSubject(
|
||||
return 'Your request has been received'
|
||||
case 'enterprise-subscription':
|
||||
return `Your Enterprise Plan is now active on ${brandName}`
|
||||
case 'usage-threshold':
|
||||
return `You're nearing your monthly budget 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}`
|
||||
default:
|
||||
return brandName
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderPlanWelcomeEmail(params: {
|
||||
planName: 'Pro' | 'Team'
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
PlanWelcomeEmail({
|
||||
planName: params.planName,
|
||||
userName: params.userName,
|
||||
loginLink: params.loginLink,
|
||||
createdDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -43,7 +42,7 @@ export const ResetPasswordEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal file
123
apps/sim/components/emails/usage-threshold-email.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { baseStyles } from './base-styles'
|
||||
|
||||
interface UsageThresholdEmailProps {
|
||||
userName?: string
|
||||
planName: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
ctaLink: string
|
||||
updatedDate?: Date
|
||||
}
|
||||
|
||||
export function UsageThresholdEmail({
|
||||
userName,
|
||||
planName,
|
||||
percentUsed,
|
||||
currentUsage,
|
||||
limit,
|
||||
ctaLink,
|
||||
updatedDate = new Date(),
|
||||
}: UsageThresholdEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
|
||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You're approaching your monthly budget on the {planName} plan.
|
||||
</Text>
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column>
|
||||
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
|
||||
<strong>Usage</strong>
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph }}>
|
||||
To avoid interruptions, consider increasing your monthly limit.
|
||||
</Text>
|
||||
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Review limits</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 80%.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsageThresholdEmail
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
@@ -64,7 +63,7 @@ export const WorkspaceInvitationEmail = ({
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || getAssetUrl('static/sim.png')}
|
||||
src={brand.logoUrl || '/logo/reverse/text/medium.png'}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
|
||||
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal file
2
apps/sim/db/migrations/0085_daffy_blacklash.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "settings" ADD COLUMN "billing_usage_notifications_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" DROP COLUMN "general";
|
||||
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
6024
apps/sim/db/migrations/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,13 @@
|
||||
"when": 1757046301281,
|
||||
"tag": "0084_even_lockheed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1757348840739,
|
||||
"tag": "0085_daffy_blacklash",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -374,8 +374,10 @@ export const settings = pgTable('settings', {
|
||||
// Email preferences
|
||||
emailPreferences: json('email_preferences').notNull().default('{}'),
|
||||
|
||||
// Keep general for future flexible settings
|
||||
general: json('general').notNull().default('{}'),
|
||||
// Billing usage notifications preference
|
||||
billingUsageNotificationsEnabled: boolean('billing_usage_notifications_enabled')
|
||||
.notNull()
|
||||
.default(true),
|
||||
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -1229,6 +1229,17 @@ export const auth = betterAuth({
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Send welcome email for Pro and Team plans
|
||||
try {
|
||||
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
|
||||
await sendPlanWelcomeEmail(subscription)
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionComplete] Failed to send plan welcome email', {
|
||||
error,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSubscriptionUpdate: async ({
|
||||
subscription,
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription, userStats } from '@/db/schema'
|
||||
import { member, subscription, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('SubscriptionCore')
|
||||
|
||||
@@ -74,7 +75,6 @@ export async function getHighestPrioritySubscription(userId: string) {
|
||||
*/
|
||||
export async function isProPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// In development, enable Pro features for easier testing
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
@@ -155,7 +155,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// Calculate usage limit
|
||||
let limit = getFreeTierLimit() // Default free tier limit
|
||||
|
||||
if (subscription) {
|
||||
@@ -283,3 +282,54 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email for Pro and Team plan subscriptions
|
||||
*/
|
||||
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
|
||||
try {
|
||||
const subPlan = subscription.plan
|
||||
if (subPlan === 'pro' || subPlan === 'team') {
|
||||
const userId = subscription.referenceId
|
||||
const users = await db
|
||||
.select({ email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0 && users[0].email) {
|
||||
const { getEmailSubject, renderPlanWelcomeEmail } = await import(
|
||||
'@/components/emails/render-email'
|
||||
)
|
||||
const { sendEmail } = await import('@/lib/email/mailer')
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
const html = await renderPlanWelcomeEmail({
|
||||
planName: subPlan === 'pro' ? 'Pro' : 'Team',
|
||||
userName: users[0].name || undefined,
|
||||
loginLink: `${baseUrl}/login`,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: users[0].email,
|
||||
subject: getEmailSubject(subPlan === 'pro' ? 'plan-welcome-pro' : 'plan-welcome-team'),
|
||||
html,
|
||||
emailType: 'updates',
|
||||
})
|
||||
|
||||
logger.info('Plan welcome email sent successfully', {
|
||||
userId,
|
||||
email: users[0].email,
|
||||
plan: subPlan,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send plan welcome email', {
|
||||
error,
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
@@ -6,9 +7,12 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getEmailPreferences } from '@/lib/email/unsubscribe'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, user, userStats } from '@/db/schema'
|
||||
import { member, organization, settings, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UsageManagement')
|
||||
|
||||
@@ -531,3 +535,89 @@ export async function calculateBillingProjection(userId: string): Promise<Billin
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send usage threshold notification when crossing from <80% to ≥80%.
|
||||
* - Skips when billing is disabled.
|
||||
* - Respects user-level notifications toggle and unsubscribe preferences.
|
||||
* - For organization plans, emails owners/admins who have notifications enabled.
|
||||
*/
|
||||
export async function maybeSendUsageThresholdEmail(params: {
|
||||
scope: 'user' | 'organization'
|
||||
planName: string
|
||||
percentBefore: number
|
||||
percentAfter: number
|
||||
userId?: string
|
||||
userEmail?: string
|
||||
userName?: string
|
||||
organizationId?: string
|
||||
currentUsageAfter: number
|
||||
limit: number
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!isBillingEnabled) return
|
||||
// Only on upward crossing to >= 80%
|
||||
if (!(params.percentBefore < 80 && params.percentAfter >= 80)) return
|
||||
if (params.limit <= 0 || params.currentUsageAfter <= 0) return
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
const ctaLink = `${baseUrl}/workspace?billing=usage`
|
||||
const sendTo = async (email: string, name?: string) => {
|
||||
const prefs = await getEmailPreferences(email)
|
||||
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
|
||||
|
||||
const html = await renderUsageThresholdEmail({
|
||||
userName: name,
|
||||
planName: params.planName,
|
||||
percentUsed: Math.min(100, Math.round(params.percentAfter)),
|
||||
currentUsage: params.currentUsageAfter,
|
||||
limit: params.limit,
|
||||
ctaLink,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject('usage-threshold'),
|
||||
html,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
}
|
||||
|
||||
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 sendTo(params.userEmail, params.userName)
|
||||
} else if (params.scope === 'organization' && params.organizationId) {
|
||||
const admins = await db
|
||||
.select({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
enabled: settings.billingUsageNotificationsEnabled,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(settings, eq(settings.userId, member.userId))
|
||||
.where(eq(member.organizationId, params.organizationId))
|
||||
|
||||
for (const a of admins) {
|
||||
const isAdmin = a.role === 'owner' || a.role === 'admin'
|
||||
if (!isAdmin) continue
|
||||
if (a.enabled === false) continue
|
||||
if (!a.email) continue
|
||||
await sendTo(a.email, a.name || undefined)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send usage threshold email', {
|
||||
scope: params.scope,
|
||||
userId: params.userId,
|
||||
organizationId: params.organizationId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
isEnterprisePlan as hasEnterprisePlan,
|
||||
isProPlan as hasProPlan,
|
||||
isTeamPlan as hasTeamPlan,
|
||||
sendPlanWelcomeEmail,
|
||||
} from '@/lib/billing/core/subscription'
|
||||
export * from '@/lib/billing/core/usage'
|
||||
export {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
|
||||
@@ -14,7 +16,14 @@ import type {
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import { db } from '@/db'
|
||||
import { userStats, workflow, workflowExecutionLogs } from '@/db/schema'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
userStats,
|
||||
user as userTable,
|
||||
workflow,
|
||||
workflowExecutionLogs,
|
||||
} from '@/db/schema'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
@@ -173,12 +182,127 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
throw new Error(`Workflow log not found for execution ${executionId}`)
|
||||
}
|
||||
|
||||
// Update user stats with cost information (same logic as original execution logger)
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
try {
|
||||
const [wf] = await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId))
|
||||
if (wf) {
|
||||
const [usr] = await db
|
||||
.select({ id: userTable.id, email: userTable.email, name: userTable.name })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, wf.userId))
|
||||
.limit(1)
|
||||
|
||||
if (usr?.email) {
|
||||
const sub = await getHighestPrioritySubscription(usr.id)
|
||||
|
||||
const costMultiplier = getCostMultiplier()
|
||||
const costDelta =
|
||||
(costSummary.baseExecutionCharge || 0) + (costSummary.modelCost || 0) * costMultiplier
|
||||
|
||||
const planName = sub?.plan || 'Free'
|
||||
const scope: 'user' | 'organization' =
|
||||
sub && (sub.plan === 'team' || sub.plan === 'enterprise') ? 'organization' : 'user'
|
||||
|
||||
if (scope === 'user') {
|
||||
const before = await checkUsageStatus(usr.id)
|
||||
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
|
||||
const limit = before.usageData.limit
|
||||
const percentBefore = before.usageData.percentUsed
|
||||
const percentAfter =
|
||||
limit > 0 ? Math.min(100, percentBefore + (costDelta / limit) * 100) : percentBefore
|
||||
const currentUsageAfter = before.usageData.currentUsage + costDelta
|
||||
|
||||
await maybeSendUsageThresholdEmail({
|
||||
scope: 'user',
|
||||
userId: usr.id,
|
||||
userEmail: usr.email,
|
||||
userName: usr.name || undefined,
|
||||
planName,
|
||||
percentBefore,
|
||||
percentAfter,
|
||||
currentUsageAfter,
|
||||
limit,
|
||||
})
|
||||
} else if (sub?.referenceId) {
|
||||
let orgLimit = 0
|
||||
const orgRows = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.limit(1)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
const minimum = (sub.seats || 1) * basePrice
|
||||
if (orgRows.length > 0 && orgRows[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgRows[0].orgUsageLimit)
|
||||
orgLimit = Math.max(configured, minimum)
|
||||
} else {
|
||||
orgLimit = minimum
|
||||
}
|
||||
|
||||
const [{ sum: orgUsageBefore }] = await db
|
||||
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
.limit(1)
|
||||
const orgUsageBeforeNum = Number.parseFloat(
|
||||
(orgUsageBefore as any)?.toString?.() || '0'
|
||||
)
|
||||
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
|
||||
const percentBefore =
|
||||
orgLimit > 0 ? Math.min(100, (orgUsageBeforeNum / orgLimit) * 100) : 0
|
||||
const percentAfter =
|
||||
orgLimit > 0
|
||||
? Math.min(100, percentBefore + (costDelta / orgLimit) * 100)
|
||||
: percentBefore
|
||||
const currentUsageAfter = orgUsageBeforeNum + costDelta
|
||||
|
||||
await maybeSendUsageThresholdEmail({
|
||||
scope: 'organization',
|
||||
organizationId: sub.referenceId,
|
||||
planName,
|
||||
percentBefore,
|
||||
percentAfter,
|
||||
currentUsageAfter,
|
||||
limit: orgLimit,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
} catch {}
|
||||
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
|
||||
}
|
||||
|
||||
logger.debug(`Completed workflow execution ${executionId}`)
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isConsoleExpandedByDefaultLoading: false,
|
||||
isThemeLoading: false, // Keep for compatibility but not used
|
||||
isTelemetryLoading: false,
|
||||
isBillingUsageNotificationsLoading: false,
|
||||
isBillingUsageNotificationsEnabled: true,
|
||||
}
|
||||
|
||||
// Optimistic update helper
|
||||
@@ -133,6 +135,16 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
)
|
||||
},
|
||||
|
||||
setBillingUsageNotificationsEnabled: async (enabled: boolean) => {
|
||||
if (get().isBillingUsageNotificationsLoading) return
|
||||
await updateSettingOptimistic(
|
||||
'isBillingUsageNotificationsEnabled',
|
||||
enabled,
|
||||
'isBillingUsageNotificationsLoading',
|
||||
'isBillingUsageNotificationsEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
// API Actions
|
||||
loadSettings: async (force = false) => {
|
||||
// Skip if we've already loaded from DB and not forcing
|
||||
@@ -193,6 +205,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ export interface General {
|
||||
telemetryEnabled: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
// Individual loading states for optimistic updates
|
||||
isAutoConnectLoading: boolean
|
||||
isAutoPanLoading: boolean
|
||||
isConsoleExpandedByDefaultLoading: boolean
|
||||
isThemeLoading: boolean
|
||||
isTelemetryLoading: boolean
|
||||
isBillingUsageNotificationsLoading: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
export interface GeneralActions {
|
||||
@@ -23,6 +24,7 @@ export interface GeneralActions {
|
||||
toggleDebugMode: () => void
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
|
||||
setTelemetryEnabled: (enabled: boolean) => Promise<void>
|
||||
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
loadSettings: (force?: boolean) => Promise<void>
|
||||
updateSetting: <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => Promise<void>
|
||||
}
|
||||
@@ -35,4 +37,5 @@ export type UserSettings = {
|
||||
autoPan: boolean
|
||||
consoleExpandedByDefault: boolean
|
||||
telemetryEnabled: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user