mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-02 18:55:25 -05:00
Compare commits
2 Commits
fix/billin
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd57927746 | ||
|
|
a213ad98cf |
@@ -20,7 +20,6 @@ import { z } from 'zod'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -502,18 +501,6 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(session.user.id)
|
||||
} catch (syncError) {
|
||||
logger.error('Failed to sync usage limits after joining org', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
@@ -89,14 +88,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Check if org already has an active subscription (prevent duplicates)
|
||||
if (await hasActiveSubscription(organizationId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization already has an active subscription' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
|
||||
@@ -203,10 +203,6 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
}
|
||||
|
||||
updateData.billingBlocked = body.billingBlocked
|
||||
// Clear the reason when unblocking
|
||||
if (body.billingBlocked === false) {
|
||||
updateData.billingBlockedReason = null
|
||||
}
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { db, workflow as workflowTable } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
@@ -6,7 +8,6 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
|
||||
@@ -74,31 +75,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { startBlockId, sourceSnapshot, input } = validation.data
|
||||
const executionId = uuidv4()
|
||||
|
||||
// Run preprocessing checks (billing, rate limits, usage limits)
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType: 'manual',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: false, // Manual executions don't rate limit
|
||||
checkDeployment: false, // Run-from-block doesn't require deployment
|
||||
})
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
const { error } = preprocessResult
|
||||
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
|
||||
workflowId,
|
||||
error: error?.message,
|
||||
statusCode: error?.statusCode,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'Execution blocked' },
|
||||
{ status: error?.statusCode || 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const workflowRecord = preprocessResult.workflowRecord
|
||||
if (!workflowRecord?.workspaceId) {
|
||||
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
|
||||
}
|
||||
@@ -110,7 +92,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowId,
|
||||
startBlockId,
|
||||
executedBlocksCount: sourceSnapshot.executedBlocks.length,
|
||||
billingActorUserId: preprocessResult.actorUserId,
|
||||
})
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
|
||||
const logger = createLogger('SocketContext')
|
||||
|
||||
@@ -138,6 +139,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
const [authFailed, setAuthFailed] = useState(false)
|
||||
const initializedRef = useRef(false)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const triggerOfflineMode = useOperationQueueStore((state) => state.triggerOfflineMode)
|
||||
|
||||
const params = useParams()
|
||||
const urlWorkflowId = params?.workflowId as string | undefined
|
||||
@@ -341,9 +343,12 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
})
|
||||
})
|
||||
|
||||
socketInstance.on('join-workflow-error', ({ error }) => {
|
||||
socketInstance.on('join-workflow-error', ({ error, code }) => {
|
||||
isRejoiningRef.current = false
|
||||
logger.error('Failed to join workflow:', error)
|
||||
logger.error('Failed to join workflow:', { error, code })
|
||||
if (code === 'ROOM_MANAGER_UNAVAILABLE') {
|
||||
triggerOfflineMode()
|
||||
}
|
||||
})
|
||||
|
||||
socketInstance.on('workflow-operation', (data) => {
|
||||
|
||||
@@ -1,37 +1,20 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('BillingAuthorization')
|
||||
|
||||
/**
|
||||
* Check if a user is authorized to manage billing for a given reference ID
|
||||
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
|
||||
*
|
||||
* This function also performs duplicate subscription validation for organizations:
|
||||
* - Rejects if an organization already has an active subscription (prevents duplicates)
|
||||
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
|
||||
*/
|
||||
export async function authorizeSubscriptionReference(
|
||||
userId: string,
|
||||
referenceId: string
|
||||
): Promise<boolean> {
|
||||
// User can always manage their own subscriptions (Pro upgrades, etc.)
|
||||
// User can always manage their own subscriptions
|
||||
if (referenceId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For organizations: check for existing active subscriptions to prevent duplicates
|
||||
if (await hasActiveSubscription(referenceId)) {
|
||||
logger.warn('Blocking checkout - active subscription already exists for organization', {
|
||||
userId,
|
||||
referenceId,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if referenceId is an organizationId the user has admin rights to
|
||||
const members = await db
|
||||
.select()
|
||||
|
||||
@@ -25,11 +25,9 @@ export function useSubscriptionUpgrade() {
|
||||
}
|
||||
|
||||
let currentSubscriptionId: string | undefined
|
||||
let allSubscriptions: any[] = []
|
||||
try {
|
||||
const listResult = await client.subscription.list()
|
||||
allSubscriptions = listResult.data || []
|
||||
const activePersonalSub = allSubscriptions.find(
|
||||
const activePersonalSub = listResult.data?.find(
|
||||
(sub: any) => sub.status === 'active' && sub.referenceId === userId
|
||||
)
|
||||
currentSubscriptionId = activePersonalSub?.id
|
||||
@@ -52,25 +50,6 @@ export function useSubscriptionUpgrade() {
|
||||
)
|
||||
|
||||
if (existingOrg) {
|
||||
// Check if this org already has an active team subscription
|
||||
const existingTeamSub = allSubscriptions.find(
|
||||
(sub: any) =>
|
||||
sub.status === 'active' &&
|
||||
sub.referenceId === existingOrg.id &&
|
||||
(sub.plan === 'team' || sub.plan === 'enterprise')
|
||||
)
|
||||
|
||||
if (existingTeamSub) {
|
||||
logger.warn('Organization already has an active team subscription', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
existingSubscriptionId: existingTeamSub.id,
|
||||
})
|
||||
throw new Error(
|
||||
'This organization already has an active team subscription. Please manage it from the billing settings.'
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Using existing organization for team plan upgrade', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
@@ -26,22 +26,10 @@ export async function getHighestPrioritySubscription(userId: string) {
|
||||
|
||||
let orgSubs: typeof personalSubs = []
|
||||
if (orgIds.length > 0) {
|
||||
// Verify orgs exist to filter out orphaned subscriptions
|
||||
const existingOrgs = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(inArray(organization.id, orgIds))
|
||||
|
||||
const validOrgIds = existingOrgs.map((o) => o.id)
|
||||
|
||||
if (validOrgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
|
||||
)
|
||||
}
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
@@ -25,28 +25,6 @@ const logger = createLogger('SubscriptionCore')
|
||||
|
||||
export { getHighestPrioritySubscription }
|
||||
|
||||
/**
|
||||
* Check if a referenceId (user ID or org ID) has an active subscription
|
||||
* Used for duplicate subscription prevention
|
||||
*
|
||||
* Fails closed: returns true on error to prevent duplicate creation
|
||||
*/
|
||||
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
|
||||
try {
|
||||
const [activeSub] = await db
|
||||
.select({ id: subscription.id })
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return !!activeSub
|
||||
} catch (error) {
|
||||
logger.error('Error checking active subscription', { error, referenceId })
|
||||
// Fail closed: assume subscription exists to prevent duplicate creation
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on Pro plan (direct or via organization)
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasAccessControlAccess,
|
||||
hasActiveSubscription,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
@@ -33,11 +32,6 @@ export {
|
||||
} from '@/lib/billing/core/usage'
|
||||
export * from '@/lib/billing/credits/balance'
|
||||
export * from '@/lib/billing/credits/purchase'
|
||||
export {
|
||||
blockOrgMembers,
|
||||
getOrgMemberIds,
|
||||
unblockOrgMembers,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
export * from '@/lib/billing/subscriptions/utils'
|
||||
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
|
||||
export * from '@/lib/billing/types'
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
|
||||
@@ -160,16 +159,6 @@ export async function ensureOrganizationForTeamSubscription(
|
||||
if (existingMembership.length > 0) {
|
||||
const membership = existingMembership[0]
|
||||
if (membership.role === 'owner' || membership.role === 'admin') {
|
||||
// Check if org already has an active subscription (prevent duplicates)
|
||||
if (await hasActiveSubscription(membership.organizationId)) {
|
||||
logger.error('Organization already has an active subscription', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
newSubscriptionId: subscription.id,
|
||||
})
|
||||
throw new Error('Organization already has an active subscription')
|
||||
}
|
||||
|
||||
logger.info('User already owns/admins an org, using it', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
|
||||
@@ -15,86 +15,13 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
|
||||
const logger = createLogger('OrganizationMembership')
|
||||
|
||||
export type BillingBlockReason = 'payment_failed' | 'dispute'
|
||||
|
||||
/**
|
||||
* Get all member user IDs for an organization
|
||||
*/
|
||||
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
return members.map((m) => m.userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block all members of an organization for billing reasons
|
||||
* Returns the number of members actually blocked
|
||||
*
|
||||
* Reason priority: dispute > payment_failed
|
||||
* A payment_failed block won't overwrite an existing dispute block
|
||||
*/
|
||||
export async function blockOrgMembers(
|
||||
organizationId: string,
|
||||
reason: BillingBlockReason
|
||||
): Promise<number> {
|
||||
const memberIds = await getOrgMemberIds(organizationId)
|
||||
|
||||
if (memberIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
|
||||
const whereClause =
|
||||
reason === 'payment_failed'
|
||||
? and(
|
||||
inArray(userStats.userId, memberIds),
|
||||
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
|
||||
)
|
||||
: inArray(userStats.userId, memberIds)
|
||||
|
||||
const result = await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: reason })
|
||||
.where(whereClause)
|
||||
.returning({ userId: userStats.userId })
|
||||
|
||||
return result.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock all members of an organization blocked for a specific reason
|
||||
* Only unblocks members blocked for the specified reason (not other reasons)
|
||||
* Returns the number of members actually unblocked
|
||||
*/
|
||||
export async function unblockOrgMembers(
|
||||
organizationId: string,
|
||||
reason: BillingBlockReason
|
||||
): Promise<number> {
|
||||
const memberIds = await getOrgMemberIds(organizationId)
|
||||
|
||||
if (memberIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
|
||||
.returning({ userId: userStats.userId })
|
||||
|
||||
return result.length
|
||||
}
|
||||
|
||||
export interface RestoreProResult {
|
||||
restored: boolean
|
||||
usageRestored: boolean
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription, user, userStats } from '@sim/db/schema'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
|
||||
const logger = createLogger('DisputeWebhooks')
|
||||
@@ -58,34 +57,36 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await blockOrgMembers(orgId, 'dispute')
|
||||
|
||||
if (memberCount > 0) {
|
||||
logger.warn('Blocked all org members due to dispute', {
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.warn('Blocked org owner due to dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
|
||||
*
|
||||
* Status meanings:
|
||||
* - 'won': Merchant won, customer's chargeback denied → unblock
|
||||
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
|
||||
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won
|
||||
*/
|
||||
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
const dispute = event.data.object as Stripe.Dispute
|
||||
|
||||
// Only unblock if we won or the warning was closed without a full dispute
|
||||
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
|
||||
|
||||
if (!shouldUnblock) {
|
||||
logger.info('Dispute resolved against us, user remains blocked', {
|
||||
if (dispute.status !== 'won') {
|
||||
logger.info('Dispute not won, user remains blocked', {
|
||||
disputeId: dispute.id,
|
||||
status: dispute.status,
|
||||
})
|
||||
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
|
||||
// Find and unblock user (Pro plans)
|
||||
const users = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
@@ -108,17 +109,16 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
|
||||
.where(eq(userStats.userId, users[0].id))
|
||||
|
||||
logger.info('Unblocked user after dispute resolved in our favor', {
|
||||
logger.info('Unblocked user after winning dispute', {
|
||||
disputeId: dispute.id,
|
||||
userId: users[0].id,
|
||||
status: dispute.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
|
||||
// Find and unblock org owner (Team/Enterprise)
|
||||
const subs = await db
|
||||
.select({ referenceId: subscription.referenceId })
|
||||
.from(subscription)
|
||||
@@ -127,13 +127,24 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await unblockOrgMembers(orgId, 'dispute')
|
||||
|
||||
logger.info('Unblocked all org members after dispute resolved in our favor', {
|
||||
disputeId: dispute.id,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
status: dispute.status,
|
||||
})
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.info('Unblocked org owner after winning dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -503,7 +502,24 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
}
|
||||
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
const memberIds = members.map((m) => m.userId)
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(
|
||||
and(
|
||||
inArray(userStats.userId, memberIds),
|
||||
eq(userStats.billingBlockedReason, 'payment_failed')
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
@@ -600,26 +616,28 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
if (records.length > 0) {
|
||||
const sub = records[0]
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
const memberIds = members.map((m) => m.userId)
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
}
|
||||
logger.info('Blocked team/enterprise members due to payment failure', {
|
||||
organizationId: sub.referenceId,
|
||||
memberCount,
|
||||
memberCount: members.length,
|
||||
isOverageInvoice,
|
||||
})
|
||||
} else {
|
||||
// Don't overwrite dispute blocks (dispute > payment_failed priority)
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(
|
||||
and(
|
||||
eq(userStats.userId, sub.referenceId),
|
||||
or(
|
||||
ne(userStats.billingBlockedReason, 'dispute'),
|
||||
isNull(userStats.billingBlockedReason)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
logger.info('Blocked user due to payment failure', {
|
||||
userId: sub.referenceId,
|
||||
isOverageInvoice,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
@@ -53,37 +52,14 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
|
||||
|
||||
/**
|
||||
* Cleanup organization when team/enterprise subscription is deleted.
|
||||
* - Checks if other active subscriptions point to this org (skip deletion if so)
|
||||
* - Restores member Pro subscriptions
|
||||
* - Deletes the organization (only if no other active subs)
|
||||
* - Deletes the organization
|
||||
* - Syncs usage limits for former members (resets to free or Pro tier)
|
||||
*/
|
||||
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
|
||||
restoredProCount: number
|
||||
membersSynced: number
|
||||
organizationDeleted: boolean
|
||||
}> {
|
||||
// Check if other active subscriptions still point to this org
|
||||
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
|
||||
// before this handler runs, so we only find truly active ones
|
||||
if (await hasActiveSubscription(organizationId)) {
|
||||
logger.info('Skipping organization deletion - other active subscriptions exist', {
|
||||
organizationId,
|
||||
})
|
||||
|
||||
// Still sync limits for members since this subscription was deleted
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
for (const m of memberUserIds) {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
|
||||
}
|
||||
|
||||
// Get member userIds before deletion (needed for limit syncing after org deletion)
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
|
||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,14 +172,15 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const { restoredProCount, membersSynced, organizationDeleted } =
|
||||
await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||
subscription.referenceId
|
||||
)
|
||||
|
||||
logger.info('Successfully processed enterprise subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
restoredProCount,
|
||||
organizationDeleted,
|
||||
organizationDeleted: true,
|
||||
membersSynced,
|
||||
})
|
||||
return
|
||||
@@ -320,7 +297,7 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
restoredProCount = cleanup.restoredProCount
|
||||
membersSynced = cleanup.membersSynced
|
||||
organizationDeleted = cleanup.organizationDeleted
|
||||
organizationDeleted = true
|
||||
} else if (subscription.plan === 'pro') {
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
membersSynced = 1
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
WorkflowExecutionSnapshot,
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
@@ -504,7 +503,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get workspace and fallback userId
|
||||
// Get the workflow record to get the userId
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
@@ -516,12 +515,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
let billingUserId: string | null = null
|
||||
if (workflowRecord.workspaceId) {
|
||||
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
}
|
||||
|
||||
const userId = billingUserId || workflowRecord.userId
|
||||
const userId = workflowRecord.userId
|
||||
const costToStore = costSummary.totalCost
|
||||
|
||||
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -12,15 +12,49 @@ import {
|
||||
import { persistWorkflowOperation } from '@/socket/database/operations'
|
||||
import type { AuthenticatedSocket } from '@/socket/middleware/auth'
|
||||
import { checkRolePermission } from '@/socket/middleware/permissions'
|
||||
import type { IRoomManager } from '@/socket/rooms'
|
||||
import type { IRoomManager, UserSession } from '@/socket/rooms'
|
||||
import { WorkflowOperationSchema } from '@/socket/validation/schemas'
|
||||
|
||||
const logger = createLogger('OperationsHandlers')
|
||||
|
||||
export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
|
||||
socket.on('workflow-operation', async (data) => {
|
||||
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||
const session = await roomManager.getUserSession(socket.id)
|
||||
if (!roomManager.isReady()) {
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'ROOM_MANAGER_UNAVAILABLE',
|
||||
message: 'Realtime unavailable',
|
||||
})
|
||||
if (data?.operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId: data.operationId,
|
||||
error: 'Realtime unavailable',
|
||||
retryable: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let workflowId: string | null = null
|
||||
let session: UserSession | null = null
|
||||
|
||||
try {
|
||||
workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||
session = await roomManager.getUserSession(socket.id)
|
||||
} catch (error) {
|
||||
logger.error('Error loading session for workflow operation:', error)
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'ROOM_MANAGER_UNAVAILABLE',
|
||||
message: 'Realtime unavailable',
|
||||
})
|
||||
if (data?.operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId: data.operationId,
|
||||
error: 'Realtime unavailable',
|
||||
retryable: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflowId || !session) {
|
||||
socket.emit('operation-forbidden', {
|
||||
|
||||
@@ -48,6 +48,21 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
operationId,
|
||||
} = data
|
||||
|
||||
if (!roomManager.isReady()) {
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'ROOM_MANAGER_UNAVAILABLE',
|
||||
message: 'Realtime unavailable',
|
||||
})
|
||||
if (operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: 'Realtime unavailable',
|
||||
retryable: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||
const session = await roomManager.getUserSession(socket.id)
|
||||
|
||||
@@ -37,6 +37,21 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
socket.on('variable-update', async (data) => {
|
||||
const { workflowId: payloadWorkflowId, variableId, field, value, timestamp, operationId } = data
|
||||
|
||||
if (!roomManager.isReady()) {
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'ROOM_MANAGER_UNAVAILABLE',
|
||||
message: 'Realtime unavailable',
|
||||
})
|
||||
if (operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: 'Realtime unavailable',
|
||||
retryable: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||
const session = await roomManager.getUserSession(socket.id)
|
||||
|
||||
@@ -20,6 +20,15 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
return
|
||||
}
|
||||
|
||||
if (!roomManager.isReady()) {
|
||||
logger.warn(`Join workflow rejected: Room manager unavailable`)
|
||||
socket.emit('join-workflow-error', {
|
||||
error: 'Realtime unavailable',
|
||||
code: 'ROOM_MANAGER_UNAVAILABLE',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Join workflow request from ${userId} (${userName}) for workflow ${workflowId}`)
|
||||
|
||||
// Verify workflow access
|
||||
@@ -128,12 +137,20 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
// Undo socket.join and room manager entry if any operation failed
|
||||
socket.leave(workflowId)
|
||||
await roomManager.removeUserFromRoom(socket.id)
|
||||
socket.emit('join-workflow-error', { error: 'Failed to join workflow' })
|
||||
const isReady = roomManager.isReady()
|
||||
socket.emit('join-workflow-error', {
|
||||
error: isReady ? 'Failed to join workflow' : 'Realtime unavailable',
|
||||
code: isReady ? undefined : 'ROOM_MANAGER_UNAVAILABLE',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('leave-workflow', async () => {
|
||||
try {
|
||||
if (!roomManager.isReady()) {
|
||||
return
|
||||
}
|
||||
|
||||
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||
const session = await roomManager.getUserSession(socket.id)
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export class MemoryRoomManager implements IRoomManager {
|
||||
logger.info('MemoryRoomManager initialized (single-pod mode)')
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.workflowRooms.clear()
|
||||
this.socketToWorkflow.clear()
|
||||
|
||||
@@ -96,17 +96,6 @@ export class RedisRoomManager implements IRoomManager {
|
||||
this._io = io
|
||||
this.redis = createClient({
|
||||
url: redisUrl,
|
||||
socket: {
|
||||
reconnectStrategy: (retries) => {
|
||||
if (retries > 10) {
|
||||
logger.error('Redis reconnection failed after 10 attempts')
|
||||
return new Error('Redis reconnection failed')
|
||||
}
|
||||
const delay = Math.min(retries * 100, 3000)
|
||||
logger.warn(`Redis reconnecting in ${delay}ms (attempt ${retries})`)
|
||||
return delay
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
this.redis.on('error', (err) => {
|
||||
@@ -122,12 +111,21 @@ export class RedisRoomManager implements IRoomManager {
|
||||
logger.info('Redis client ready')
|
||||
this.isConnected = true
|
||||
})
|
||||
|
||||
this.redis.on('end', () => {
|
||||
logger.warn('Redis client connection closed')
|
||||
this.isConnected = false
|
||||
})
|
||||
}
|
||||
|
||||
get io(): Server {
|
||||
return this._io
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.isConnected
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isConnected) return
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface IRoomManager {
|
||||
*/
|
||||
initialize(): Promise<void>
|
||||
|
||||
/**
|
||||
* Whether the room manager is ready to serve requests
|
||||
*/
|
||||
isReady(): boolean
|
||||
|
||||
/**
|
||||
* Clean shutdown
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,11 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
|
||||
res.end(JSON.stringify({ error: authResult.error }))
|
||||
return
|
||||
}
|
||||
|
||||
if (!roomManager.isReady()) {
|
||||
sendError(res, 'Room manager unavailable', 503)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle workflow deletion notifications from the main API
|
||||
|
||||
Reference in New Issue
Block a user