mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
committed by
GitHub
parent
4a703a02cb
commit
7780d9b32b
@@ -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 ||
|
||||
|
||||
@@ -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}
|
||||
|
||||
122
apps/sim/components/emails/enterprise-subscription-email.tsx
Normal file
122
apps/sim/components/emails/enterprise-subscription-email.tsx
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
251
apps/sim/lib/billing/webhooks/enterprise.ts
Normal file
251
apps/sim/lib/billing/webhooks/enterprise.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user