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:
Waleed
2025-09-08 09:47:16 -07:00
committed by GitHub
parent 07ba17422b
commit 07e70409c7
24 changed files with 6680 additions and 31 deletions

View File

@@ -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 }

View File

@@ -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>
)
}

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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={{

View File

@@ -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'

View File

@@ -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={{

View File

@@ -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={{

View 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

View File

@@ -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(),
})
)
}

View File

@@ -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={{

View 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

View File

@@ -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={{

View 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";

File diff suppressed because it is too large Load Diff

View File

@@ -589,6 +589,13 @@
"when": 1757046301281,
"tag": "0084_even_lockheed",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1757348840739,
"tag": "0085_daffy_blacklash",
"breakpoints": true
}
]
}

View File

@@ -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(),
})

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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,
})
}
}

View File

@@ -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 {

View File

@@ -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}`)

View File

@@ -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,
})

View File

@@ -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
}