fix(enterprise-billing): simplification to be fixed-cost (#1196)

* fix(enterprise-billing): simplify

* conceptual improvement

* add seats to enterprise sub meta

* correct type

* fix UI

* send emails to new enterprise users

* fix fallback

* fix merge conflict issue

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-08-30 17:26:17 -07:00
committed by GitHub
parent 4a703a02cb
commit 7780d9b32b
20 changed files with 554 additions and 416 deletions

View File

@@ -197,10 +197,10 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const activeOrgId = activeOrganization?.id
useEffect(() => {
if (subscription.isTeam && activeOrgId) {
if ((subscription.isTeam || subscription.isEnterprise) && activeOrgId) {
loadOrganizationBillingData(activeOrgId)
}
}, [activeOrgId, subscription.isTeam, loadOrganizationBillingData])
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])
// Auto-clear upgrade error
useEffect(() => {
@@ -349,22 +349,39 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
badgeText={badgeText}
onBadgeClick={handleBadgeClick}
seatsText={
permissions.canManageTeam
permissions.canManageTeam || subscription.isEnterprise
? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats`
: undefined
}
current={usage.current}
current={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalCurrentUsage || 0
: usage.current
}
limit={
!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalUsageLimit ||
organizationBillingData?.minimumBillingAmount ||
0
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={Boolean(subscriptionData?.billingBlocked)}
status={billingStatus === 'unknown' ? 'ok' : billingStatus}
percentUsed={Math.round(usage.percentUsed)}
percentUsed={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalUsageLimit &&
organizationBillingData.totalUsageLimit > 0
? Math.round(
(organizationBillingData.totalCurrentUsage /
organizationBillingData.totalUsageLimit) *
100
)
: 0
: Math.round(usage.percentUsed)
}
onResolvePayment={async () => {
try {
const res = await fetch('/api/billing/portal', {
@@ -387,9 +404,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise) ? (
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
@@ -398,7 +413,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
: usageLimitData?.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
subscription.isTeam && isTeamAdmin
? organizationBillingData?.minimumBillingAmount ||

View File

@@ -1039,6 +1039,7 @@ export function Sidebar() {
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />
<SearchModal
open={showSearchModal}
onOpenChange={setShowSearchModal}

View File

@@ -0,0 +1,122 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
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'
interface EnterpriseSubscriptionEmailProps {
userName?: string
userEmail?: string
loginLink?: string
createdDate?: Date
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
export const EnterpriseSubscriptionEmail = ({
userName = 'Valued User',
userEmail = '',
loginLink = `${baseUrl}/login`,
createdDate = new Date(),
}: EnterpriseSubscriptionEmailProps) => {
const brand = getBrandConfig()
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your Enterprise Plan is now active on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.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}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
have access to advanced features and increased capacity for your workflows.
</Text>
<Text style={baseStyles.paragraph}>
Your account has been set up with full access to your organization. Click below to log
in and start exploring your new Enterprise features:
</Text>
<Link href={loginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
</Link>
<Text style={baseStyles.paragraph}>
<strong>What's next?</strong>
</Text>
<Text style={baseStyles.paragraph}>
• Invite team members to your organization
<br />• Begin building your workflows
</Text>
<Text style={baseStyles.paragraph}>
If you have any questions or need assistance getting started, our support team is here
to help.
</Text>
<Text style={baseStyles.paragraph}>
Welcome to Sim Enterprise!
<br />
The Sim Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
regarding your Enterprise plan activation on Sim.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default EnterpriseSubscriptionEmail

View File

@@ -1,5 +1,6 @@
export * from './base-styles'
export { BatchInvitationEmail } from './batch-invitation-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { default as EmailFooter } from './footer'
export { HelpConfirmationEmail } from './help-confirmation-email'
export { InvitationEmail } from './invitation-email'

View File

@@ -1,6 +1,7 @@
import { render } from '@react-email/components'
import {
BatchInvitationEmail,
EnterpriseSubscriptionEmail,
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
@@ -82,6 +83,23 @@ export async function renderHelpConfirmationEmail(
)
}
export async function renderEnterpriseSubscriptionEmail(
userName: string,
userEmail: string
): Promise<string> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const loginLink = `${baseUrl}/login`
return await render(
EnterpriseSubscriptionEmail({
userName,
userEmail,
loginLink,
createdDate: new Date(),
})
)
}
export function getEmailSubject(
type:
| 'sign-in'
@@ -91,6 +109,7 @@ export function getEmailSubject(
| 'invitation'
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
): string {
const brandName = getBrandConfig().name
@@ -109,6 +128,8 @@ export function getEmailSubject(
return `You've been invited to join a team and workspaces on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
default:
return brandName
}

View File

@@ -24,7 +24,7 @@ import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { getPlans } from '@/lib/billing/plans'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
import {
handleInvoiceFinalized,
handleInvoicePaymentFailed,
@@ -52,121 +52,6 @@ if (validStripeKey) {
})
}
function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
return (
!!value &&
typeof (value as any).plan === 'string' &&
(value as any).plan.toLowerCase() === 'enterprise'
)
}
async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const stripeSubscription = event.data.object as Stripe.Subscription
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
if (metaPlan !== 'enterprise') {
logger.info('[subscription.created] Skipping non-enterprise subscription', {
subscriptionId: stripeSubscription.id,
plan: metaPlan || 'unknown',
})
return
}
const stripeCustomerId = stripeSubscription.customer as string
if (!stripeCustomerId) {
logger.error('[subscription.created] Missing Stripe customer ID', {
subscriptionId: stripeSubscription.id,
})
throw new Error('Missing Stripe customer ID on subscription')
}
const metadata = stripeSubscription.metadata || {}
const referenceId =
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
? metadata.referenceId
: null
if (!referenceId) {
logger.error('[subscription.created] Unable to resolve referenceId', {
subscriptionId: stripeSubscription.id,
stripeCustomerId,
})
throw new Error('Unable to resolve referenceId for subscription')
}
const firstItem = stripeSubscription.items?.data?.[0]
const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null
if (!isEnterpriseMetadata(metadata)) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const enterpriseMetadata = metadata
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }
const subscriptionRow = {
id: crypto.randomUUID(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: null,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadataJson,
}
const existing = await db
.select({ id: schema.subscription.id })
.from(schema.subscription)
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
.limit(1)
if (existing.length > 0) {
await db
.update(schema.subscription)
.set({
plan: subscriptionRow.plan,
referenceId: subscriptionRow.referenceId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
status: subscriptionRow.status,
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: subscriptionRow.seats,
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,
})
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
} else {
await db.insert(schema.subscription).values(subscriptionRow)
}
logger.info('[subscription.created] Upserted subscription', {
subscriptionId: subscriptionRow.id,
referenceId: subscriptionRow.referenceId,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
})
}
export const auth = betterAuth({
baseURL: getBaseURL(),
trustedOrigins: [

View File

@@ -31,9 +31,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const currentUsage =
statsRecords.length > 0
? Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
? Number.parseFloat(statsRecords[0].currentPeriodCost?.toString())
: 0
return {
@@ -117,7 +115,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
// Fall back to minimum billing amount from Stripe subscription
const orgSub = await getOrganizationSubscription(org.id)
if (orgSub?.seats) {
const { basePrice } = getPlanPricing(orgSub.plan, orgSub)
const { basePrice } = getPlanPricing(orgSub.plan)
orgCap = (orgSub.seats || 1) * basePrice
} else {
// If no subscription, use team default

View File

@@ -2,12 +2,10 @@ import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
import {
getEnterpriseTierLimitPerSeat,
getFreeTierLimit,
getProTierLimit,
getTeamTierLimitPerSeat,
} from '@/lib/billing/subscriptions/utils'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, subscription, user } from '@/db/schema'
@@ -43,11 +41,8 @@ export async function getOrganizationSubscription(organizationId: string) {
/**
* Get plan pricing information
*/
export function getPlanPricing(
plan: string,
subscription?: any
): {
basePrice: number // What they pay upfront via Stripe subscription (per seat for team/enterprise)
export function getPlanPricing(plan: string): {
basePrice: number // What they pay upfront via Stripe subscription
} {
switch (plan) {
case 'free':
@@ -55,25 +50,7 @@ export function getPlanPricing(
case 'pro':
return { basePrice: getProTierLimit() }
case 'team':
return { basePrice: getTeamTierLimitPerSeat() }
case 'enterprise':
// Enterprise uses per-seat pricing like Team plans
// Custom per-seat price can be set in metadata
if (subscription?.metadata) {
const metadata: EnterpriseSubscriptionMetadata =
typeof subscription.metadata === 'string'
? JSON.parse(subscription.metadata)
: subscription.metadata
const perSeatPrice = metadata.perSeatPrice
? Number.parseFloat(String(metadata.perSeatPrice))
: undefined
if (perSeatPrice && perSeatPrice > 0 && !Number.isNaN(perSeatPrice)) {
return { basePrice: perSeatPrice }
}
}
// Default enterprise per-seat pricing
return { basePrice: getEnterpriseTierLimitPerSeat() }
return { basePrice: getTeamTierLimitPerSeat() } // Per-seat pricing
default:
return { basePrice: 0 }
}
@@ -103,7 +80,7 @@ export async function calculateUserOverage(userId: string): Promise<{
}
const plan = subscription?.plan || 'free'
const { basePrice } = getPlanPricing(plan, subscription)
const { basePrice } = getPlanPricing(plan)
const actualUsage = usageData.currentUsage
// Calculate overage: any usage beyond what they already paid for
@@ -197,7 +174,7 @@ export async function getSimplifiedBillingSummary(
.from(member)
.where(eq(member.organizationId, organizationId))
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription)
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan)
// Use licensed seats from Stripe as source of truth
const licensedSeats = subscription.seats || 1
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
@@ -270,7 +247,7 @@ export async function getSimplifiedBillingSummary(
}
// Individual billing summary
const { basePrice } = getPlanPricing(plan, subscription)
const { basePrice } = getPlanPricing(plan)
// For team and enterprise plans, calculate total team usage instead of individual usage
let currentUsage = usageData.currentUsage

View File

@@ -131,35 +131,38 @@ export async function getOrganizationBillingData(
const totalCurrentUsage = members.reduce((sum, member) => sum + member.currentUsage, 0)
// Get per-seat pricing for the plan
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription)
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan)
// Use Stripe subscription seats as source of truth
// Ensure we always have at least 1 seat (protect against 0 or falsy values)
const licensedSeats = Math.max(subscription.seats || 1, 1)
// Validate seat capacity - warn if members exceed licensed seats
if (members.length > licensedSeats) {
logger.warn('Organization has more members than licensed seats', {
organizationId,
licensedSeats,
actualMembers: members.length,
plan: subscription.plan,
})
// Calculate minimum billing amount
let minimumBillingAmount: number
let totalUsageLimit: number
if (subscription.plan === 'enterprise') {
// Enterprise has fixed pricing set through custom Stripe product
// Their usage limit is configured to match their monthly cost
const configuredLimit = organizationData.orgUsageLimit
? Number.parseFloat(organizationData.orgUsageLimit)
: 0
minimumBillingAmount = configuredLimit // For enterprise, this equals their fixed monthly cost
totalUsageLimit = configuredLimit // Same as their monthly cost
} else {
// Team plan: Billing is based on licensed seats from Stripe
minimumBillingAmount = licensedSeats * pricePerSeat
// Total usage limit: never below the minimum based on licensed seats
const configuredLimit = organizationData.orgUsageLimit
? Number.parseFloat(organizationData.orgUsageLimit)
: null
totalUsageLimit =
configuredLimit !== null
? Math.max(configuredLimit, minimumBillingAmount)
: minimumBillingAmount
}
// Billing is based on licensed seats from Stripe, not actual member count
// This ensures organizations pay for their seat capacity regardless of utilization
const minimumBillingAmount = licensedSeats * pricePerSeat
// Total usage limit: never below the minimum based on licensed seats
const configuredLimit = organizationData.orgUsageLimit
? Number.parseFloat(organizationData.orgUsageLimit)
: null
const totalUsageLimit =
configuredLimit !== null
? Math.max(configuredLimit, minimumBillingAmount)
: minimumBillingAmount
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
// Billing period comes from the organization's subscription
@@ -213,8 +216,24 @@ export async function updateOrganizationUsageLimit(
return { success: false, error: 'No active subscription found' }
}
// Calculate minimum based on seats
const { basePrice } = getPlanPricing(subscription.plan, subscription)
// Enterprise plans have fixed usage limits that cannot be changed
if (subscription.plan === 'enterprise') {
return {
success: false,
error: 'Enterprise plans have fixed usage limits that cannot be changed',
}
}
// Only team plans can update their usage limits
if (subscription.plan !== 'team') {
return {
success: false,
error: 'Only team organizations can update usage limits',
}
}
// Team plans have minimum based on seats
const { basePrice } = getPlanPricing(subscription.plan)
const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice
// Validate new limit is not below minimum

View File

@@ -157,14 +157,26 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
// Calculate usage limit
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
limit = getPerUserMinimumLimit(subscription)
logger.info('Using subscription-based limit', {
userId,
plan: subscription.plan,
seats: subscription.seats || 1,
limit,
})
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
logger.info('Using organization limit', {
userId,
plan: subscription.plan,
limit,
})
} else {
// Pro/Free: Use individual limit
limit = getPerUserMinimumLimit(subscription)
logger.info('Using subscription-based limit', {
userId,
plan: subscription.plan,
limit,
})
}
} else {
logger.info('Using free tier limit', { userId, limit })
}
@@ -231,7 +243,14 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
if (isProd && statsRecords.length > 0) {
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
limit = getPerUserMinimumLimit(subscription)
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
} else {
// Pro/Free: Use individual limit
limit = getPerUserMinimumLimit(subscription)
}
}
const currentCost = Number.parseFloat(

View File

@@ -71,7 +71,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan, subscription)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
@@ -144,7 +144,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan, subscription)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
@@ -335,14 +335,14 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
if (orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan, subscription)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
return Math.max(configured, minimum)
}
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan, subscription)
const { basePrice } = getPlanPricing(subscription.plan)
return (subscription.seats || 1) * basePrice
}

View File

@@ -23,10 +23,6 @@ export {
updateUserUsageLimit as updateUsageLimit,
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/subscriptions/utils'
export {
canEditUsageLimit as canEditLimit,
getMinimumUsageLimit as getMinimumLimit,
getSubscriptionAllowance as getDefaultLimit,
} from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'
export * from '@/lib/billing/validation/seat-management'

View File

@@ -1,77 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { checkEnterprisePlan, getSubscriptionAllowance } from '@/lib/billing/subscriptions/utils'
vi.mock('@/lib/env', () => ({
env: {
FREE_TIER_COST_LIMIT: 10,
PRO_TIER_COST_LIMIT: 20,
TEAM_TIER_COST_LIMIT: 40,
ENTERPRISE_TIER_COST_LIMIT: 200,
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
describe('Subscription Utilities', () => {
describe('checkEnterprisePlan', () => {
it.concurrent('returns true for active enterprise subscription', () => {
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'active' })).toBeTruthy()
})
it.concurrent('returns false for inactive enterprise subscription', () => {
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'canceled' })).toBeFalsy()
})
it.concurrent('returns false when plan is not enterprise', () => {
expect(checkEnterprisePlan({ plan: 'pro', status: 'active' })).toBeFalsy()
})
})
describe('getSubscriptionAllowance', () => {
it.concurrent('returns free-tier limit when subscription is null', () => {
expect(getSubscriptionAllowance(null)).toBe(10)
})
it.concurrent('returns free-tier limit when subscription is undefined', () => {
expect(getSubscriptionAllowance(undefined)).toBe(10)
})
it.concurrent('returns free-tier limit when subscription is not active', () => {
expect(getSubscriptionAllowance({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10)
})
it.concurrent('returns pro limit for active pro plan', () => {
expect(getSubscriptionAllowance({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
})
it.concurrent('returns team limit multiplied by seats', () => {
expect(getSubscriptionAllowance({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
})
it.concurrent('returns enterprise limit using perSeatPrice metadata', () => {
const sub = {
plan: 'enterprise',
status: 'active',
seats: 10,
metadata: { perSeatPrice: 150 },
}
expect(getSubscriptionAllowance(sub)).toBe(10 * 150)
})
it.concurrent('returns enterprise limit using perSeatPrice as string', () => {
const sub = {
plan: 'enterprise',
status: 'active',
seats: 8,
metadata: { perSeatPrice: '250' },
}
expect(getSubscriptionAllowance(sub)).toBe(8 * 250)
})
it.concurrent('falls back to default enterprise tier when metadata missing', () => {
const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} }
expect(getSubscriptionAllowance(sub)).toBe(2 * 200)
})
})
})

View File

@@ -4,7 +4,6 @@ import {
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env } from '@/lib/env'
/**
@@ -47,51 +46,10 @@ export function checkTeamPlan(subscription: any): boolean {
return subscription?.plan === 'team' && subscription?.status === 'active'
}
/**
* Calculate the total subscription-level allowance (what the org/user gets for their base payment)
* - Pro: Fixed amount per user
* - Team: Seats * base price (pooled for the org)
* - Enterprise: Seats * per-seat price (pooled, with optional custom pricing in metadata)
* @param subscription The subscription object
* @returns The total subscription allowance in dollars
*/
export function getSubscriptionAllowance(subscription: any): number {
if (!subscription || subscription.status !== 'active') {
return getFreeTierLimit()
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return getProTierLimit()
}
if (subscription.plan === 'team') {
return seats * getTeamTierLimitPerSeat()
}
if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | undefined
// Enterprise uses per-seat pricing (pooled like Team)
// Custom per-seat price can be set in metadata
let perSeatPrice = getEnterpriseTierLimitPerSeat()
if (metadata?.perSeatPrice) {
const parsed = Number.parseFloat(String(metadata.perSeatPrice))
if (parsed > 0 && !Number.isNaN(parsed)) {
perSeatPrice = parsed
}
}
return seats * perSeatPrice
}
return getFreeTierLimit()
}
/**
* Get the minimum usage limit for an individual user (used for validation)
* - Pro: User's plan minimum
* - Team: 0 (pooled model, no individual minimums)
* - Enterprise: 0 (pooled model, no individual minimums)
* Only applicable for plans with individual limits (Free/Pro)
* Team and Enterprise plans use organization-level limits instead
* @param subscription The subscription object
* @returns The per-user minimum limit in dollars
*/
@@ -100,27 +58,15 @@ export function getPerUserMinimumLimit(subscription: any): number {
return getFreeTierLimit()
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return getProTierLimit()
}
if (subscription.plan === 'team') {
// For team plans, return the total pooled limit (seats * cost per seat)
// This becomes the user's individual limit representing their share of the team pool
return seats * getTeamTierLimitPerSeat()
}
if (subscription.plan === 'enterprise') {
// For enterprise plans, return the total pooled limit (seats * cost per seat)
// This becomes the user's individual limit representing their share of the enterprise pool
let perSeatPrice = getEnterpriseTierLimitPerSeat()
if (subscription.metadata?.perSeatPrice) {
const parsed = Number.parseFloat(String(subscription.metadata.perSeatPrice))
if (parsed > 0 && !Number.isNaN(parsed)) {
perSeatPrice = parsed
}
}
return seats * perSeatPrice
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
// Team and Enterprise don't have individual limits - they use organization limits
// This function should not be called for these plans
// Returning 0 to indicate no individual minimum
return 0
}
return getFreeTierLimit()
@@ -128,7 +74,8 @@ export function getPerUserMinimumLimit(subscription: any): number {
/**
* Check if a user can edit their usage limits based on their subscription
* Free plan users cannot edit limits, paid plan users can
* Free and Enterprise plans cannot edit limits
* Pro and Team plans can increase their limits
* @param subscription The subscription object
* @returns Whether the user can edit their usage limits
*/
@@ -137,19 +84,7 @@ export function canEditUsageLimit(subscription: any): boolean {
return false // Free plan users cannot edit limits
}
return (
subscription.plan === 'pro' ||
subscription.plan === 'team' ||
subscription.plan === 'enterprise'
)
}
/**
* Get the minimum allowed usage limit for a subscription
* This prevents users from setting limits below their plan's base amount
* @param subscription The subscription object
* @returns The minimum allowed usage limit in dollars
*/
export function getMinimumUsageLimit(subscription: any): number {
return getPerUserMinimumLimit(subscription)
// Only Pro and Team plans can edit limits
// Enterprise has fixed limits that match their monthly cost
return subscription.plan === 'pro' || subscription.plan === 'team'
}

View File

@@ -5,15 +5,15 @@
export interface EnterpriseSubscriptionMetadata {
plan: 'enterprise'
// Custom per-seat pricing (defaults to DEFAULT_ENTERPRISE_TIER_COST_LIMIT)
// The referenceId must be provided in Stripe metadata to link to the organization
// This gets stored in the subscription.referenceId column
referenceId: string
perSeatPrice?: number
// Maximum allowed seats (defaults to subscription.seats)
maxSeats?: number
// Whether seats are fixed and cannot be changed
fixedSeats?: boolean
// The fixed monthly price for this enterprise customer (as string from Stripe metadata)
// This will be used to set the organization's usage limit
monthlyPrice: string
// Number of seats for invitation limits (not for billing) (as string from Stripe metadata)
// We set Stripe quantity to 1 and use this for actual seat count
seats: string
}
export interface UsageData {

View File

@@ -1,6 +1,5 @@
import { and, count, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { quickValidateEmail } from '@/lib/email/validation'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
@@ -67,26 +66,9 @@ export async function validateSeatAvailability(
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits based on subscription
let maxSeats = subscription.seats || 1
// For enterprise plans, check metadata for custom seat allowances
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata: EnterpriseSubscriptionMetadata =
typeof subscription.metadata === 'string'
? JSON.parse(subscription.metadata)
: subscription.metadata
if (metadata.maxSeats && typeof metadata.maxSeats === 'number') {
maxSeats = metadata.maxSeats
}
} catch (error) {
logger.warn('Failed to parse enterprise subscription metadata', {
organizationId,
metadata: subscription.metadata,
error,
})
}
}
// Team: seats from Stripe subscription quantity
// Enterprise: seats from metadata (stored in subscription.seats)
const maxSeats = subscription.seats || 1
const availableSeats = Math.max(0, maxSeats - currentSeats)
const canInvite = availableSeats >= additionalSeats
@@ -162,24 +144,11 @@ export async function getOrganizationSeatInfo(
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits
let maxSeats = subscription.seats || 1
let canAddSeats = true
const maxSeats = subscription.seats || 1
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata: EnterpriseSubscriptionMetadata =
typeof subscription.metadata === 'string'
? JSON.parse(subscription.metadata)
: subscription.metadata
if (metadata.maxSeats && typeof metadata.maxSeats === 'number') {
maxSeats = metadata.maxSeats
}
// Enterprise plans might have fixed seat counts
canAddSeats = !metadata.fixedSeats
} catch (error) {
logger.warn('Failed to parse enterprise subscription metadata', { organizationId, error })
}
}
// Enterprise plans have fixed seats (can't self-serve changes)
// Team plans can add seats through Stripe
const canAddSeats = subscription.plan !== 'enterprise'
const availableSeats = Math.max(0, maxSeats - currentSeats)

View File

@@ -0,0 +1,251 @@
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import {
getEmailSubject,
renderEnterpriseSubscriptionEmail,
} from '@/components/emails/render-email'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { organization, subscription, user } from '@/db/schema'
import type { EnterpriseSubscriptionMetadata } from '../types'
const logger = createLogger('BillingEnterprise')
function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
return (
!!value &&
typeof value === 'object' &&
'plan' in value &&
'referenceId' in value &&
'monthlyPrice' in value &&
'seats' in value &&
typeof value.plan === 'string' &&
value.plan.toLowerCase() === 'enterprise' &&
typeof value.referenceId === 'string' &&
typeof value.monthlyPrice === 'string' &&
typeof value.seats === 'string'
)
}
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const stripeSubscription = event.data.object as Stripe.Subscription
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
if (metaPlan !== 'enterprise') {
logger.info('[subscription.created] Skipping non-enterprise subscription', {
subscriptionId: stripeSubscription.id,
plan: metaPlan || 'unknown',
})
return
}
const stripeCustomerId = stripeSubscription.customer as string
if (!stripeCustomerId) {
logger.error('[subscription.created] Missing Stripe customer ID', {
subscriptionId: stripeSubscription.id,
})
throw new Error('Missing Stripe customer ID on subscription')
}
const metadata = stripeSubscription.metadata || {}
const referenceId =
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
? metadata.referenceId
: null
if (!referenceId) {
logger.error('[subscription.created] Unable to resolve referenceId', {
subscriptionId: stripeSubscription.id,
stripeCustomerId,
})
throw new Error('Unable to resolve referenceId for subscription')
}
if (!isEnterpriseMetadata(metadata)) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const enterpriseMetadata = metadata
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }
// Extract and parse seats and monthly price from metadata (they come as strings from Stripe)
const seats = Number.parseInt(enterpriseMetadata.seats, 10)
const monthlyPrice = Number.parseFloat(enterpriseMetadata.monthlyPrice)
if (!seats || seats <= 0 || Number.isNaN(seats)) {
logger.error('[subscription.created] Invalid or missing seats in enterprise metadata', {
subscriptionId: stripeSubscription.id,
seatsRaw: enterpriseMetadata.seats,
seatsParsed: seats,
})
throw new Error('Enterprise subscription must include valid seats in metadata')
}
if (!monthlyPrice || monthlyPrice <= 0 || Number.isNaN(monthlyPrice)) {
logger.error('[subscription.created] Invalid or missing monthlyPrice in enterprise metadata', {
subscriptionId: stripeSubscription.id,
monthlyPriceRaw: enterpriseMetadata.monthlyPrice,
monthlyPriceParsed: monthlyPrice,
})
throw new Error('Enterprise subscription must include valid monthlyPrice in metadata')
}
const subscriptionRow = {
id: crypto.randomUUID(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: null,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadataJson,
}
const existing = await db
.select({ id: subscription.id })
.from(subscription)
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
.limit(1)
if (existing.length > 0) {
await db
.update(subscription)
.set({
plan: subscriptionRow.plan,
referenceId: subscriptionRow.referenceId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
status: subscriptionRow.status,
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: subscriptionRow.seats,
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
} else {
await db.insert(subscription).values(subscriptionRow)
}
// Update the organization's usage limit to match the monthly price
// The referenceId for enterprise plans is the organization ID
try {
await db
.update(organization)
.set({
orgUsageLimit: monthlyPrice.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, referenceId))
logger.info('[subscription.created] Updated organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
})
} catch (error) {
logger.error('[subscription.created] Failed to update organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
error,
})
// Don't throw - the subscription was created successfully, just log the error
}
logger.info('[subscription.created] Upserted enterprise subscription', {
subscriptionId: subscriptionRow.id,
referenceId: subscriptionRow.referenceId,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
monthlyPrice,
seats,
note: 'Seats from metadata, Stripe quantity set to 1',
})
try {
const userDetails = await db
.select({
id: user.id,
name: user.name,
email: user.email,
})
.from(user)
.where(eq(user.stripeCustomerId, stripeCustomerId))
.limit(1)
const orgDetails = await db
.select({
id: organization.id,
name: organization.name,
})
.from(organization)
.where(eq(organization.id, referenceId))
.limit(1)
if (userDetails.length > 0 && orgDetails.length > 0) {
const user = userDetails[0]
const org = orgDetails[0]
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email, user.email)
const emailResult = await sendEmail({
to: user.email,
subject: getEmailSubject('enterprise-subscription'),
html,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (emailResult.success) {
logger.info('[subscription.created] Enterprise subscription email sent successfully', {
userId: user.id,
email: user.email,
organizationId: org.id,
subscriptionId: subscriptionRow.id,
})
} else {
logger.warn('[subscription.created] Failed to send enterprise subscription email', {
userId: user.id,
email: user.email,
error: emailResult.message,
})
}
} else {
logger.warn(
'[subscription.created] Could not find user or organization for email notification',
{
userFound: userDetails.length > 0,
orgFound: orgDetails.length > 0,
stripeCustomerId,
referenceId,
}
)
}
} catch (emailError) {
logger.error('[subscription.created] Error sending enterprise subscription email', {
error: emailError,
stripeCustomerId,
referenceId,
subscriptionId: subscriptionRow.id,
})
}
}

View File

@@ -197,6 +197,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
/**
* Handle base invoice finalized → create a separate overage-only invoice
* Note: Enterprise plans no longer have overages
*/
export async function handleInvoiceFinalized(event: Stripe.Event) {
try {
@@ -215,14 +216,22 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
if (records.length === 0) return
const sub = records[0]
// Always reset usage at cycle end for all plans
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
// Enterprise plans have no overages - skip overage invoice creation
if (sub.plan === 'enterprise') {
return
}
const stripe = requireStripeClient()
const periodEnd =
invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
// Compute overage
// Compute overage (only for team and pro plans)
let totalOverage = 0
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (sub.plan === 'team') {
const members = await db
.select({ userId: member.userId })
.from(member)
@@ -235,19 +244,16 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
}
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan, sub)
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
} else {
const usage = await getUserUsageData(sub.referenceId)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan, sub)
const { basePrice } = getPlanPricing(sub.plan)
totalOverage = Math.max(0, usage.currentUsage - basePrice)
}
// Always reset usage at cycle end, regardless of whether overage > 0
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
if (totalOverage <= 0) return
const customerId = String(invoice.customer)

View File

@@ -132,7 +132,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@react-email/preview-server": "4.2.4",
"@react-email/preview-server": "4.2.8",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",

View File

@@ -160,7 +160,7 @@
"zod": "^3.24.2",
},
"devDependencies": {
"@react-email/preview-server": "4.2.4",
"@react-email/preview-server": "4.2.8",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -1002,7 +1002,7 @@
"@react-email/preview": ["@react-email/preview@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q=="],
"@react-email/preview-server": ["@react-email/preview-server@4.2.4", "", { "dependencies": { "@babel/core": "7.26.10", "@babel/parser": "7.27.0", "@babel/traverse": "7.27.0", "@lottiefiles/dotlottie-react": "0.13.3", "@radix-ui/colors": "3.0.0", "@radix-ui/react-collapsible": "1.1.7", "@radix-ui/react-dropdown-menu": "2.1.10", "@radix-ui/react-popover": "1.1.10", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "1.1.7", "@radix-ui/react-toggle-group": "1.1.6", "@radix-ui/react-tooltip": "1.2.3", "@types/node": "22.14.1", "@types/normalize-path": "3.0.2", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/webpack": "5.28.5", "autoprefixer": "10.4.21", "chalk": "4.1.2", "clsx": "2.1.1", "esbuild": "0.25.0", "framer-motion": "12.7.5", "json5": "2.2.3", "log-symbols": "4.1.0", "module-punycode": "npm:punycode@2.3.1", "next": "15.4.1", "node-html-parser": "7.0.1", "ora": "5.4.1", "pretty-bytes": "6.1.1", "prism-react-renderer": "2.4.1", "react": "19.0.0", "react-dom": "19.0.0", "sharp": "0.34.1", "socket.io-client": "4.8.1", "sonner": "2.0.3", "source-map-js": "1.2.1", "spamc": "0.0.5", "stacktrace-parser": "0.1.11", "tailwind-merge": "3.2.0", "tailwindcss": "3.4.0", "use-debounce": "10.0.4", "zod": "3.24.3" } }, "sha512-QRh7MUK9rG48lwIvwHoL8ByNCNkQzX9G7hl8T+IsleI55lGeAtlAzze/QHeLfoYZ7wl5LCG05ok/00DP06Xogw=="],
"@react-email/preview-server": ["@react-email/preview-server@4.2.8", "", { "dependencies": { "@babel/core": "7.26.10", "@babel/parser": "7.27.0", "@babel/traverse": "7.27.0", "@lottiefiles/dotlottie-react": "0.13.3", "@radix-ui/colors": "3.0.0", "@radix-ui/react-collapsible": "1.1.7", "@radix-ui/react-dropdown-menu": "2.1.10", "@radix-ui/react-popover": "1.1.10", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "1.1.7", "@radix-ui/react-toggle-group": "1.1.6", "@radix-ui/react-tooltip": "1.2.3", "@types/node": "22.14.1", "@types/normalize-path": "3.0.2", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/webpack": "5.28.5", "autoprefixer": "10.4.21", "chalk": "4.1.2", "clsx": "2.1.1", "esbuild": "0.25.0", "framer-motion": "12.23.12", "json5": "2.2.3", "log-symbols": "4.1.0", "module-punycode": "npm:punycode@2.3.1", "next": "15.4.1", "node-html-parser": "7.0.1", "ora": "5.4.1", "pretty-bytes": "6.1.1", "prism-react-renderer": "2.4.1", "react": "19.0.0", "react-dom": "19.0.0", "sharp": "0.34.1", "socket.io-client": "4.8.1", "sonner": "2.0.3", "source-map-js": "1.2.1", "spamc": "0.0.5", "stacktrace-parser": "0.1.11", "tailwind-merge": "3.2.0", "tailwindcss": "3.4.0", "use-debounce": "10.0.4", "zod": "3.24.3" } }, "sha512-q/Y4VQtFsrOiTYAAh84M+acu04OROz1Ay2RQCWX6+5GlM+gZkq4tXiE7TXfTj4dFdPkPvU3mCr6LP6Y2yPnXNg=="],
"@react-email/render": ["@react-email/render@1.0.5", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.4.2", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ=="],