diff --git a/packages/server/src/IdentityManager.ts b/packages/server/src/IdentityManager.ts index c56903be9c2..d7b26597e87 100644 --- a/packages/server/src/IdentityManager.ts +++ b/packages/server/src/IdentityManager.ts @@ -20,6 +20,7 @@ import { LoginMethodStatus } from './enterprise/database/entities/login-method.e import { ErrorMessage, LoggedInUser } from './enterprise/Interface.Enterprise' import { Permissions } from './enterprise/rbac/Permissions' import { LoginMethodService } from './enterprise/services/login-method.service' +import { Organization, OrganizationStatus } from './enterprise/database/entities/organization.entity' import { OrganizationService } from './enterprise/services/organization.service' import Auth0SSO from './enterprise/sso/Auth0SSO' import AzureSSO from './enterprise/sso/AzureSSO' @@ -320,13 +321,18 @@ export class IdentityManager { return await this.stripeManager.getAdditionalSeatsProration(subscriptionId, newQuantity) } - public async updateAdditionalSeats(subscriptionId: string, quantity: number, prorationDate: number) { + public async updateAdditionalSeats(subscriptionId: string, quantity: number, prorationDate: number, increase: boolean) { if (!subscriptionId) return {} if (!this.stripeManager) { throw new Error('Stripe manager is not initialized') } - const { success, subscription, invoice } = await this.stripeManager.updateAdditionalSeats(subscriptionId, quantity, prorationDate) + const { success, subscription, invoice, paymentFailed, paymentError } = await this.stripeManager.updateAdditionalSeats( + subscriptionId, + quantity, + prorationDate, + increase + ) // Fetch product details to get quotas const items = subscription.items.data @@ -358,7 +364,13 @@ export class IdentityManager { subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription) }) - return { success, subscription, invoice } + return { + success, + subscription, + invoice, + paymentFailed, + paymentError: paymentFailed ? paymentError?.message || 'Payment failed' : null + } } public async getPlanProration(subscriptionId: string, newPlanId: string) { @@ -379,85 +391,138 @@ export class IdentityManager { if (!req.user) { throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, GeneralErrorMessage.UNAUTHORIZED) } - const { success, subscription } = await this.stripeManager.updateSubscriptionPlan(subscriptionId, newPlanId, prorationDate) - if (success) { - // Fetch product details to get quotas - const product = await this.stripeManager.getStripe().products.retrieve(newPlanId) - const productMetadata = product.metadata - - // Extract quotas from metadata - const quotas: Record = {} - for (const key in productMetadata) { - if (key.startsWith('quota:')) { - quotas[key] = parseInt(productMetadata[key]) + try { + const result = await this.stripeManager.updateSubscriptionPlan(subscriptionId, newPlanId, prorationDate) + const { success, subscription, special_case, paymentFailed, paymentError } = result + if (success) { + // Handle special case: downgrade from past_due to free plan + if (special_case === 'downgrade_from_past_due') { + // Update organization status to active using OrganizationService + const queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() + await queryRunner.connect() + + try { + const organizationService = new OrganizationService() + + // Find organization by subscriptionId + const organization = await queryRunner.manager.findOne(Organization, { + where: { subscriptionId } + }) + + if (organization) { + await organizationService.updateOrganization( + { + id: organization.id, + status: OrganizationStatus.ACTIVE, + updatedBy: req.user.id + }, + queryRunner, + true // fromStripe = true to allow status updates + ) + } + } finally { + await queryRunner.release() + } } - } - const additionalSeatsItem = subscription.items.data.find( - (item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID - ) - quotas[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT] = additionalSeatsItem?.quantity || 0 + // Fetch product details to get quotas + const product = await this.stripeManager.getStripe().products.retrieve(newPlanId) + const productMetadata = product.metadata - // Get features from Stripe - const features = await this.getFeaturesByPlan(subscription.id, true) + // Extract quotas from metadata + const quotas: Record = {} + for (const key in productMetadata) { + if (key.startsWith('quota:')) { + quotas[key] = parseInt(productMetadata[key]) + } + } - // Update the cache with new subscription data including quotas - const cacheManager = await UsageCacheManager.getInstance() + const additionalSeatsItem = subscription.items.data.find( + (item: any) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID + ) + quotas[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT] = additionalSeatsItem?.quantity || 0 - const updateCacheData: Record = { - features, - quotas, - subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription) - } + // Get features from Stripe + const features = await this.getFeaturesByPlan(subscription.id, true) - if ( - newPlanId === process.env.CLOUD_FREE_ID || - newPlanId === process.env.CLOUD_STARTER_ID || - newPlanId === process.env.CLOUD_PRO_ID - ) { - updateCacheData.productId = newPlanId - } + // Update the cache with new subscription data including quotas + const cacheManager = await UsageCacheManager.getInstance() - await cacheManager.updateSubscriptionDataToCache(subscriptionId, updateCacheData) + const updateCacheData: Record = { + features, + quotas, + subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription) + } - const loggedInUser: LoggedInUser = { - ...req.user, - activeOrganizationSubscriptionId: subscription.id, - features - } + if ( + newPlanId === process.env.CLOUD_FREE_ID || + newPlanId === process.env.CLOUD_STARTER_ID || + newPlanId === process.env.CLOUD_PRO_ID + ) { + updateCacheData.productId = newPlanId + } - if ( - newPlanId === process.env.CLOUD_FREE_ID || - newPlanId === process.env.CLOUD_STARTER_ID || - newPlanId === process.env.CLOUD_PRO_ID - ) { - loggedInUser.activeOrganizationProductId = newPlanId - } + await cacheManager.updateSubscriptionDataToCache(subscriptionId, updateCacheData) - req.user = { - ...req.user, - ...loggedInUser - } + const loggedInUser: LoggedInUser = { + ...req.user, + activeOrganizationSubscriptionId: subscription.id, + features + } - // Update passport session - // @ts-ignore - req.session.passport.user = { - ...req.user, - ...loggedInUser - } + if ( + newPlanId === process.env.CLOUD_FREE_ID || + newPlanId === process.env.CLOUD_STARTER_ID || + newPlanId === process.env.CLOUD_PRO_ID + ) { + loggedInUser.activeOrganizationProductId = newPlanId + } - req.session.save((err) => { - if (err) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, GeneralErrorMessage.UNHANDLED_EDGE_CASE) - }) + req.user = { + ...req.user, + ...loggedInUser + } + + // Update passport session + // @ts-ignore + req.session.passport.user = { + ...req.user, + ...loggedInUser + } + + req.session.save((err) => { + if (err) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, GeneralErrorMessage.UNHANDLED_EDGE_CASE) + }) + return { + status: 'success', + user: loggedInUser, + paymentFailed, + paymentError: paymentFailed ? paymentError?.message || 'Payment failed' : null + } + } return { - status: 'success', - user: loggedInUser + status: 'error', + message: 'Payment or subscription update not completed' } - } - return { - status: 'error', - message: 'Payment or subscription update not completed' + } catch (error: any) { + // Enhanced error handling for payment method failures + if (error.type === 'StripeCardError' || error.code === 'card_declined') { + throw new InternalFlowiseError( + StatusCodes.PAYMENT_REQUIRED, + 'Your payment method was declined. Please update your payment method and try again.' + ) + } + + if (error.type === 'StripeInvalidRequestError' && error.message?.includes('payment_method')) { + throw new InternalFlowiseError( + StatusCodes.PAYMENT_REQUIRED, + 'There was an issue with your payment method. Please update your payment method and try again.' + ) + } + + // Re-throw other errors + throw error } } diff --git a/packages/server/src/StripeManager.ts b/packages/server/src/StripeManager.ts index 278370ea1e8..e25d514458d 100644 --- a/packages/server/src/StripeManager.ts +++ b/packages/server/src/StripeManager.ts @@ -2,7 +2,9 @@ import Stripe from 'stripe' import { Request } from 'express' import { UsageCacheManager } from './UsageCacheManager' import { UserPlan } from './Interface' -import { LICENSE_QUOTAS } from './utils/constants' +import { GeneralErrorMessage, LICENSE_QUOTAS } from './utils/constants' +import { InternalFlowiseError } from './errors/internalFlowiseError' +import { StatusCodes } from 'http-status-codes' export class StripeManager { private static instance: StripeManager @@ -304,8 +306,8 @@ export class StripeManager { const seatPrice = prices.data[0] const pricePerSeat = seatPrice.unit_amount || 0 - // Use current timestamp for proration calculation - const prorationDate = Math.floor(Date.now() / 1000) + // TODO: Fix proration date for sandbox testing - use subscription period bounds + const prorationDate = this.calculateSafeProrationDate(subscription) const additionalSeatsItem = subscription.items.data.find( (item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID @@ -359,44 +361,74 @@ export class StripeManager { } } - public async updateAdditionalSeats(subscriptionId: string, quantity: number, prorationDate: number) { + public async updateAdditionalSeats(subscriptionId: string, quantity: number, _prorationDate: number, increase: boolean) { if (!this.stripe) { throw new Error('Stripe is not initialized') } try { - const subscription = await this.stripe.subscriptions.retrieve(subscriptionId) - const additionalSeatsItem = subscription.items.data.find( - (item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID - ) - // Get the price ID for additional seats if needed const prices = await this.stripe.prices.list({ product: process.env.ADDITIONAL_SEAT_ID, active: true, limit: 1 }) - if (prices.data.length === 0) { throw new Error('No active price found for additional seats') } + const openInvoices = await this.stripe.invoices.list({ + subscription: subscriptionId, + status: 'open' + }) + const openAdditionalSeatsInvoices = openInvoices.data.filter((invoice) => + invoice.lines?.data?.some((line) => line.price?.id === prices.data[0].id) + ) + if (openAdditionalSeatsInvoices.length > 0 && increase === true) + throw new InternalFlowiseError(StatusCodes.PAYMENT_REQUIRED, "Not allow to add seats when there're unsuccessful payment") + + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId) + const additionalSeatsItem = subscription.items.data.find( + (item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID + ) + + // TODO: Fix proration date for sandbox testing - use subscription period bounds + const adjustedProrationDate = this.calculateSafeProrationDate(subscription) + // Create an invoice immediately for the proration - const updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, { + const subscriptionUpdateData: any = { items: [ additionalSeatsItem ? { - id: additionalSeatsItem.id, - quantity: quantity + id: additionalSeatsItem.id } : { - price: prices.data[0].id, - quantity: quantity + price: prices.data[0].id } - ], - proration_behavior: 'always_invoice', - proration_date: prorationDate - }) + ] + } + if (openAdditionalSeatsInvoices.length > 0) { + let newQuantity = 0 + // When there is a paid and unpaid lines in the invoice, we need to remove the unpaid quantity of that invoice + if (openAdditionalSeatsInvoices[0].lines.data.length > 1) { + openAdditionalSeatsInvoices[0].lines.data.forEach((line) => { + if (line.amount < 0) newQuantity += line.quantity ?? 0 + }) + // If there is only one line in the invoice, we need to remove the whole quantity of that invoice + } else if (openAdditionalSeatsInvoices[0].lines.data.length === 1) { + newQuantity = 0 + } else { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, GeneralErrorMessage.UNHANDLED_EDGE_CASE) + } + quantity = newQuantity + await this.stripe.invoices.voidInvoice(openAdditionalSeatsInvoices[0].id) + subscriptionUpdateData.proration_behavior = 'none' + } else { + ;(subscriptionUpdateData.proration_behavior = 'always_invoice'), + (subscriptionUpdateData.proration_date = adjustedProrationDate) + } + subscriptionUpdateData.items[0].quantity = quantity + const updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, subscriptionUpdateData) // Get the latest invoice for this subscription const invoice = await this.stripe.invoices.list({ @@ -404,18 +436,33 @@ export class StripeManager { limit: 1 }) + let paymentFailed = false + let paymentError: any = null + if (invoice.data.length > 0) { const latestInvoice = invoice.data[0] // Only try to pay if the invoice is not already paid if (latestInvoice.status !== 'paid') { - await this.stripe.invoices.pay(latestInvoice.id) + try { + await this.stripe.invoices.pay(latestInvoice.id) + } catch (error: any) { + // Payment failed but we still want to provision access + // This keeps Stripe and our app in sync - both will show the new seats + // Stripe will retry payment for a few days, then send invoice.marked_uncollectible + // Our webhook will handle setting org status to past_due at that point + paymentFailed = true + paymentError = error + console.error('Payment failed during additional seats update, but provisioning access anyway:', error) + } } } return { success: true, subscription: updatedSubscription, - invoice: invoice.data[0] + invoice: invoice.data[0], + paymentFailed, // Indicates if payment failed but seats were still updated + paymentError: paymentFailed ? paymentError : null // Error details for frontend display } } catch (error) { console.error('Error updating additional seats:', error) @@ -456,22 +503,50 @@ export class StripeManager { const hasUsedFirstMonthFreeCoupon = customerMetadata.has_used_first_month_free === 'true' const eligibleForFirstMonthFree = isStarterPlan && !hasUsedFirstMonthFreeCoupon - // Use current timestamp for proration calculation - const prorationDate = Math.floor(Date.now() / 1000) + // TODO: Fix proration date for sandbox testing - use subscription period bounds + const subscriptionForProration = await this.stripe.subscriptions.retrieve(subscriptionId) + const prorationDate = this.calculateSafeProrationDate(subscriptionForProration) + + // Check if this is a downgrade to free plan (Issue 1) + const isDowngradeToFree = newPlanId === process.env.CLOUD_FREE_ID + let prorationBehavior: 'always_invoice' | 'none' = 'always_invoice' + + if (isDowngradeToFree) { + // Get the latest invoice to determine proration behavior + const latestInvoicesList = await this.stripe.invoices.list({ + subscription: subscriptionId, + limit: 1 + }) + + if (latestInvoicesList.data.length > 0) { + const latestInvoice = latestInvoicesList.data[0] + // Issue 1: Check if latest invoice was paid and non-zero + prorationBehavior = latestInvoice.status === 'paid' && latestInvoice.amount_paid > 0 ? 'always_invoice' : 'none' + } else { + // No invoices found, use 'none' for free plan downgrade + prorationBehavior = 'none' + } + } + + const subscriptionDetails: any = { + proration_behavior: prorationBehavior, + items: [ + { + id: subscription.items.data[0].id, + price: newPlan.id + } + ] + } + + // Only set proration_date if we're actually doing proration + if (prorationBehavior === 'always_invoice') { + subscriptionDetails.proration_date = prorationDate + } const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({ customer: customerId, subscription: subscriptionId, - subscription_details: { - proration_behavior: 'always_invoice', - proration_date: prorationDate, - items: [ - { - id: subscription.items.data[0].id, - price: newPlan.id - } - ] - } + subscription_details: subscriptionDetails }) let prorationAmount = upcomingInvoice.lines.data.reduce((total, item) => total + item.amount, 0) @@ -487,7 +562,8 @@ export class StripeManager { prorationDate, currentPeriodStart: subscription.current_period_start, currentPeriodEnd: subscription.current_period_end, - eligibleForFirstMonthFree + eligibleForFirstMonthFree, + prorationBehavior } } catch (error) { console.error('Error calculating plan proration:', error) @@ -495,7 +571,21 @@ export class StripeManager { } } - public async updateSubscriptionPlan(subscriptionId: string, newPlanId: string, prorationDate: number) { + /** + * Helper function to calculate a safe proration date within subscription period bounds + * TODO: Remove this helper when sandbox testing is complete + */ + private calculateSafeProrationDate(subscription: any): number { + return Math.max( + subscription.current_period_start + 60, // At least 1 minute into current period + Math.min( + Math.floor(Date.now() / 1000) - 60, // Prefer current time minus 1 minute + subscription.current_period_end - 60 // But no later than 1 minute before period end + ) + ) + } + + public async updateSubscriptionPlan(subscriptionId: string, newPlanId: string, _prorationDate: number) { if (!this.stripe) { throw new Error('Stripe is not initialized') } @@ -526,7 +616,89 @@ export class StripeManager { const isStarterPlan = newPlanId === process.env.CLOUD_STARTER_ID const hasUsedFirstMonthFreeCoupon = customerMetadata.has_used_first_month_free === 'true' - if (isStarterPlan && !hasUsedFirstMonthFreeCoupon) { + // Check if this is a downgrade to free plan + const isDowngradeToFree = newPlanId === process.env.CLOUD_FREE_ID + + // Handle downgrade to free plan during retry period (Issues 1 & 2) + if (isDowngradeToFree) { + // Get the latest invoice + const latestInvoicesList = await this.stripe.invoices.list({ + subscription: subscriptionId, + limit: 1, + status: 'open' + }) + + if (latestInvoicesList.data.length > 0) { + const latestInvoice = latestInvoicesList.data[0] + + // Check if the subscription is in past_due and invoice is in retry + if (subscription.status === 'past_due' && latestInvoice.status === 'open') { + // Issue 2: Void the latest invoice and activate subscription + await this.stripe.invoices.voidInvoice(latestInvoice.id) + + // Update subscription to free plan + updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, { + items: [ + { + id: subscription.items.data[0].id, + price: newPlan.id + } + ], + proration_behavior: 'none' + }) + + // Create a $0 invoice and mark it as paid + const zeroInvoice = await this.stripe.invoices.create({ + customer: customerId, + subscription: subscriptionId, + collection_method: 'charge_automatically', + auto_advance: false + }) + + await this.stripe.invoices.pay(zeroInvoice.id) + + return { + success: true, + subscription: updatedSubscription, + invoice: zeroInvoice, + special_case: 'downgrade_from_past_due' + } + } else { + // Issue 1: Check if latest invoice was paid and non-zero + const prorationBehavior = + latestInvoice.status === 'paid' && latestInvoice.amount_paid > 0 ? 'always_invoice' : 'none' + + const subscriptionUpdateData: any = { + items: [ + { + id: subscription.items.data[0].id, + price: newPlan.id + } + ], + proration_behavior: prorationBehavior + } + + // Only set proration_date if we're actually doing proration + if (prorationBehavior === 'always_invoice') { + // TODO: Fix proration date for sandbox testing - use subscription period bounds + subscriptionUpdateData.proration_date = this.calculateSafeProrationDate(subscription) + } + + updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, subscriptionUpdateData) + } + } else { + // No invoices found, proceed with normal downgrade + updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, { + items: [ + { + id: subscription.items.data[0].id, + price: newPlan.id + } + ], + proration_behavior: 'none' + }) + } + } else if (isStarterPlan && !hasUsedFirstMonthFreeCoupon) { // Create the one-time 100% off coupon const coupon = await this.stripe.coupons.create({ duration: 'once', @@ -545,6 +717,9 @@ export class StripeManager { max_redemptions: 1 }) + // TODO: Fix proration date for sandbox testing - use subscription period bounds + const adjustedProrationDate = this.calculateSafeProrationDate(subscription) + // Update the subscription with the new plan and apply the promotion code updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, { items: [ @@ -554,7 +729,7 @@ export class StripeManager { } ], proration_behavior: 'always_invoice', - proration_date: prorationDate, + proration_date: adjustedProrationDate, promotion_code: promotionCode.id }) @@ -567,6 +742,9 @@ export class StripeManager { } }) } else { + // TODO: Fix proration date for sandbox testing - use subscription period bounds + const adjustedProrationDate = this.calculateSafeProrationDate(subscription) + // Regular plan update without coupon updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, { items: [ @@ -576,27 +754,42 @@ export class StripeManager { } ], proration_behavior: 'always_invoice', - proration_date: prorationDate + proration_date: adjustedProrationDate }) } - // Get and pay the latest invoice + // Get and pay the latest invoice (only if not a special case) const invoice = await this.stripe.invoices.list({ subscription: subscriptionId, limit: 1 }) + let paymentFailed = false + let paymentError: any = null + if (invoice.data.length > 0) { const latestInvoice = invoice.data[0] if (latestInvoice.status !== 'paid') { - await this.stripe.invoices.pay(latestInvoice.id) + try { + await this.stripe.invoices.pay(latestInvoice.id) + } catch (error: any) { + // Payment failed but we still want to provision access + // This keeps Stripe and our app in sync - both will show the new plan + // Stripe will retry payment for a few days, then send invoice.marked_uncollectible + // Our webhook will handle setting org status to past_due at that point + paymentFailed = true + paymentError = error + console.error('Payment failed during upgrade, but provisioning access anyway:', error) + } } } return { success: true, subscription: updatedSubscription, - invoice: invoice.data[0] + invoice: invoice.data[0], + paymentFailed, // Indicates if payment failed but plan was still upgraded + paymentError: paymentFailed ? paymentError : null // Error details for frontend display } } catch (error) { console.error('Error updating subscription plan:', error) diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index 272a6bb1ff2..04aa1ec7d88 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -48,6 +48,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mariadb/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mariadb/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mariadb/1746862866554-ExecutionLinkWorkspaceId' +import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/mariadb/1749714174104-AddStatusInOrganization' export const mariadbMigrations = [ Init1693840429259, @@ -98,5 +99,6 @@ export const mariadbMigrations = [ FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionDataColumnType1747902489801 + ModifyExecutionDataColumnType1747902489801, + AddStatusInOrganization1749714174104 ] diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index c51ebb8a945..3d29cb1f198 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -49,6 +49,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mysql/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mysql/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mysql/1746862866554-ExecutionLinkWorkspaceId' +import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/mysql/1749714174104-AddStatusInOrganization' export const mysqlMigrations = [ Init1693840429259, @@ -100,5 +101,6 @@ export const mysqlMigrations = [ AddErrorToEvaluationRun1744964560174, FixErrorsColumnInEvaluationRun1746437114935, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionDataColumnType1747902489801 + ModifyExecutionDataColumnType1747902489801, + AddStatusInOrganization1749714174104 ] diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 4da17daa4ab..57919a9e8f8 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -48,6 +48,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/postgres/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/postgres/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/postgres/1746862866554-ExecutionLinkWorkspaceId' +import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/postgres/1749714174104-AddStatusInOrganization' export const postgresMigrations = [ Init1693891895163, @@ -98,5 +99,6 @@ export const postgresMigrations = [ FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, ExecutionLinkWorkspaceId1746862866554, - ModifyExecutionSessionIdFieldType1748450230238 + ModifyExecutionSessionIdFieldType1748450230238, + AddStatusInOrganization1749714174104 ] diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 0b15e26938f..9b7456591e4 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -46,6 +46,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/sqlite/1734074497540-AddPersonalWorkspace' import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/sqlite/1737076223692-RefactorEnterpriseDatabase' import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/sqlite/1746862866554-ExecutionLinkWorkspaceId' +import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/sqlite/1749714174104-AddStatusInOrganization' export const sqliteMigrations = [ Init1693835579790, @@ -94,5 +95,6 @@ export const sqliteMigrations = [ AddExecutionEntity1738090872625, FixOpenSourceAssistantTable1743758056188, AddErrorToEvaluationRun1744964560174, - ExecutionLinkWorkspaceId1746862866554 + ExecutionLinkWorkspaceId1746862866554, + AddStatusInOrganization1749714174104 ] diff --git a/packages/server/src/enterprise/controllers/organization.controller.ts b/packages/server/src/enterprise/controllers/organization.controller.ts index b7ca0a6d7a7..de11fa81625 100644 --- a/packages/server/src/enterprise/controllers/organization.controller.ts +++ b/packages/server/src/enterprise/controllers/organization.controller.ts @@ -1,12 +1,13 @@ -import { Request, Response, NextFunction } from 'express' +import { NextFunction, Request, Response } from 'express' import { StatusCodes } from 'http-status-codes' -import { OrganizationErrorMessage, OrganizationService } from '../services/organization.service' -import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { QueryRunner } from 'typeorm' import { InternalFlowiseError } from '../../errors/internalFlowiseError' -import { Organization } from '../database/entities/organization.entity' import { GeneralErrorMessage } from '../../utils/constants' -import { OrganizationUserService } from '../services/organization-user.service' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { getCurrentUsage } from '../../utils/quotaUsage' +import { Organization } from '../database/entities/organization.entity' +import { OrganizationUserService } from '../services/organization-user.service' +import { OrganizationErrorMessage, OrganizationService } from '../services/organization.service' export class OrganizationController { public async create(req: Request, res: Response, next: NextFunction) { @@ -47,12 +48,18 @@ export class OrganizationController { } public async update(req: Request, res: Response, next: NextFunction) { + let queryRunner: QueryRunner | undefined try { + queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() + await queryRunner.connect() const organizationService = new OrganizationService() - const organization = await organizationService.updateOrganization(req.body) + const organization = await organizationService.updateOrganization(req.body, queryRunner) return res.status(StatusCodes.OK).json(organization) } catch (error) { + if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction() next(error) + } finally { + if (queryRunner && !queryRunner.isReleased) await queryRunner.release() } } @@ -127,7 +134,7 @@ export class OrganizationController { public async updateAdditionalSeats(req: Request, res: Response, next: NextFunction) { try { - const { subscriptionId, quantity, prorationDate } = req.body + const { subscriptionId, quantity, prorationDate, increase } = req.body if (!subscriptionId) { return res.status(400).json({ error: 'Subscription ID is required' }) } @@ -137,8 +144,10 @@ export class OrganizationController { if (!prorationDate) { return res.status(400).json({ error: 'Proration date is required' }) } + if (increase === undefined) return res.status(StatusCodes.BAD_REQUEST).json({ error: 'Increase is required' }) + const identityManager = getRunningExpressApp().identityManager - const result = await identityManager.updateAdditionalSeats(subscriptionId, quantity, prorationDate) + const result = await identityManager.updateAdditionalSeats(subscriptionId, quantity, prorationDate, increase) return res.status(StatusCodes.OK).json(result) } catch (error) { diff --git a/packages/server/src/enterprise/database/entities/organization.entity.ts b/packages/server/src/enterprise/database/entities/organization.entity.ts index 1f6ad47ca57..baca30be449 100644 --- a/packages/server/src/enterprise/database/entities/organization.entity.ts +++ b/packages/server/src/enterprise/database/entities/organization.entity.ts @@ -5,6 +5,12 @@ export enum OrganizationName { DEFAULT_ORGANIZATION = 'Default Organization' } +export enum OrganizationStatus { + ACTIVE = 'active', + UNDER_REVIEW = 'under_review', + PAST_DUE = 'past_due' +} + @Entity() export class Organization { @PrimaryGeneratedColumn('uuid') @@ -19,6 +25,9 @@ export class Organization { @Column({ type: 'varchar', length: 100, nullable: true }) subscriptionId?: string + @Column({ type: 'varchar', length: 20, default: OrganizationStatus.ACTIVE }) + status: string + @CreateDateColumn() createdDate?: Date diff --git a/packages/server/src/enterprise/database/migrations/mariadb/1749714174104-AddStatusInOrganization.ts b/packages/server/src/enterprise/database/migrations/mariadb/1749714174104-AddStatusInOrganization.ts new file mode 100644 index 00000000000..1bc9e25a46e --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/mariadb/1749714174104-AddStatusInOrganization.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { OrganizationStatus } from '../../entities/organization.entity' + +export class AddStatusInOrganization1749714174104 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`alter table \`organization\` add \`status\` varchar(20) default "${OrganizationStatus.ACTIVE}" not null ;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`alter table \`organization\` drop column \`status\` ;`) + } +} diff --git a/packages/server/src/enterprise/database/migrations/mysql/1749714174104-AddStatusInOrganization.ts b/packages/server/src/enterprise/database/migrations/mysql/1749714174104-AddStatusInOrganization.ts new file mode 100644 index 00000000000..1bc9e25a46e --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/mysql/1749714174104-AddStatusInOrganization.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { OrganizationStatus } from '../../entities/organization.entity' + +export class AddStatusInOrganization1749714174104 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`alter table \`organization\` add \`status\` varchar(20) default "${OrganizationStatus.ACTIVE}" not null ;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`alter table \`organization\` drop column \`status\` ;`) + } +} diff --git a/packages/server/src/enterprise/database/migrations/postgres/1749714174104-AddStatusInOrganization.ts b/packages/server/src/enterprise/database/migrations/postgres/1749714174104-AddStatusInOrganization.ts new file mode 100644 index 00000000000..5043046936d --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/postgres/1749714174104-AddStatusInOrganization.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { OrganizationStatus } from '../../entities/organization.entity' + +export class AddStatusInOrganization1749714174104 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(`alter table "organization" add column "status" varchar(20) default '${OrganizationStatus.ACTIVE}' not null;`) + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(`alter table "organization" drop column "status";`) + } +} diff --git a/packages/server/src/enterprise/database/migrations/sqlite/1749714174104-AddStatusInOrganization.ts b/packages/server/src/enterprise/database/migrations/sqlite/1749714174104-AddStatusInOrganization.ts new file mode 100644 index 00000000000..5043046936d --- /dev/null +++ b/packages/server/src/enterprise/database/migrations/sqlite/1749714174104-AddStatusInOrganization.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { OrganizationStatus } from '../../entities/organization.entity' + +export class AddStatusInOrganization1749714174104 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(`alter table "organization" add column "status" varchar(20) default '${OrganizationStatus.ACTIVE}' not null;`) + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(`alter table "organization" drop column "status";`) + } +} diff --git a/packages/server/src/enterprise/services/organization.service.ts b/packages/server/src/enterprise/services/organization.service.ts index 9ee11546712..d4416d9df25 100644 --- a/packages/server/src/enterprise/services/organization.service.ts +++ b/packages/server/src/enterprise/services/organization.service.ts @@ -4,12 +4,13 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { generateId } from '../../utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { Telemetry } from '../../utils/telemetry' -import { Organization, OrganizationName } from '../database/entities/organization.entity' +import { Organization, OrganizationName, OrganizationStatus } from '../database/entities/organization.entity' import { isInvalidName, isInvalidUUID } from '../utils/validation.util' import { UserErrorMessage, UserService } from './user.service' export const enum OrganizationErrorMessage { INVALID_ORGANIZATION_ID = 'Invalid Organization Id', + INVALID_ORGANIZATION_STATUS = 'Invalid Organization Status', INVALID_ORGANIZATION_NAME = 'Invalid Organization Name', ORGANIZATION_NOT_FOUND = 'Organization Not Found', ORGANIZATION_FOUND_MULTIPLE = 'Organization Found Multiple', @@ -32,6 +33,12 @@ export class OrganizationService { if (isInvalidUUID(id)) throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, OrganizationErrorMessage.INVALID_ORGANIZATION_ID) } + public validateOrganizationStatus(status: string | undefined) { + if (status && !Object.values(OrganizationStatus).includes(status as OrganizationStatus)) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, OrganizationErrorMessage.INVALID_ORGANIZATION_STATUS) + } + } + public async readOrganizationById(id: string | undefined, queryRunner: QueryRunner) { this.validateOrganizationId(id) return await queryRunner.manager.findOneBy(Organization, { id }) @@ -49,6 +56,10 @@ export class OrganizationService { return await queryRunner.manager.findOneBy(Organization, { name }) } + public async readOrganizationBySubscriptionId(subscriptionId: typeof Organization.prototype.subscriptionId, queryRunner: QueryRunner) { + return await queryRunner.manager.findOneBy(Organization, { subscriptionId }) + } + public async countOrganizations(queryRunner: QueryRunner) { return await queryRunner.manager.count(Organization) } @@ -59,6 +70,8 @@ export class OrganizationService { public createNewOrganization(data: Partial, queryRunner: QueryRunner, isRegister: boolean = false) { this.validateOrganizationName(data.name, isRegister) + // REMARK: status is not allowed to be set when creating a new organization + if (data.status) delete data.status data.updatedBy = data.createdBy data.id = generateId() @@ -91,30 +104,20 @@ export class OrganizationService { return newOrganization } - public async updateOrganization(newOrganizationData: Partial) { - const queryRunner = this.dataSource.createQueryRunner() - await queryRunner.connect() - + public async updateOrganization(newOrganizationData: Partial, queryRunner: QueryRunner, fromStripe: boolean = false) { const oldOrganizationData = await this.readOrganizationById(newOrganizationData.id, queryRunner) if (!oldOrganizationData) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, OrganizationErrorMessage.ORGANIZATION_NOT_FOUND) const user = await this.userService.readUserById(newOrganizationData.updatedBy, queryRunner) if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND) - if (newOrganizationData.name) { - this.validateOrganizationName(newOrganizationData.name) - } + if (newOrganizationData.name) this.validateOrganizationName(newOrganizationData.name) + // TODO: allow flowise's employees to modify organization status + // REMARK: status is only allowed to be set when updating an organization from stripe + if (fromStripe === true && newOrganizationData.status) this.validateOrganizationStatus(newOrganizationData.status) + else if (newOrganizationData.status) delete newOrganizationData.status newOrganizationData.createdBy = oldOrganizationData.createdBy let updateOrganization = queryRunner.manager.merge(Organization, oldOrganizationData, newOrganizationData) - try { - await queryRunner.startTransaction() - await this.saveOrganization(updateOrganization, queryRunner) - await queryRunner.commitTransaction() - } catch (error) { - await queryRunner.rollbackTransaction() - throw error - } finally { - await queryRunner.release() - } + await this.saveOrganization(updateOrganization, queryRunner) return updateOrganization } diff --git a/packages/server/src/enterprise/services/stripe.service.ts b/packages/server/src/enterprise/services/stripe.service.ts new file mode 100644 index 00000000000..8d56336162a --- /dev/null +++ b/packages/server/src/enterprise/services/stripe.service.ts @@ -0,0 +1,208 @@ +import Stripe from 'stripe' +import { QueryRunner } from 'typeorm' +import { StripeManager } from '../../StripeManager' +import { UsageCacheManager } from '../../UsageCacheManager' +import { Organization, OrganizationStatus } from '../database/entities/organization.entity' +import { OrganizationUser } from '../database/entities/organization-user.entity' +import { Workspace, WorkspaceName } from '../database/entities/workspace.entity' +import { WorkspaceUser } from '../database/entities/workspace-user.entity' +import { OrganizationErrorMessage, OrganizationService } from './organization.service' +import logger from '../../utils/logger' + +enum InvoiceStatus { + DRAFT = 'draft', + OPEN = 'open', + PAID = 'paid', + UNCOLLECTIBLE = 'uncollectible', + VOID = 'void' +} + +// Note: Organization entity will have a 'status' field added later +// This will support values like 'active', 'suspended', etc. + +export class StripeService { + private stripe: Stripe + + constructor() { + // stripe will be initialized in methods that use it + } + + private async getStripe(): Promise { + if (!this.stripe) { + const stripeManager = await StripeManager.getInstance() + this.stripe = stripeManager.getStripe() + } + return this.stripe + } + + public async reactivateOrganizationIfEligible(invoice: Stripe.Invoice, queryRunner: QueryRunner): Promise { + try { + await this.getStripe() // Initialize stripe if not already done + + if (!invoice.subscription) { + logger.warn(`No subscription ID found in invoice: ${invoice.id}`) + return + } + + const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription.id + + const organizationService = new OrganizationService() + const organization = await organizationService.readOrganizationBySubscriptionId(subscriptionId, queryRunner) + if (!organization) { + logger.warn(`${OrganizationErrorMessage.ORGANIZATION_NOT_FOUND} for subscription ID: ${subscriptionId}`) + return + } + + if (organization.status === OrganizationStatus.ACTIVE) { + logger.info(`Organization ${organization.id} is already active`) + return + } + + if (organization.status === OrganizationStatus.UNDER_REVIEW) { + logger.info(`Organization ${organization.id} is under review`) + return + } + + const uncollectibleInvoices = await this.stripe.invoices.list({ + subscription: subscriptionId, + status: InvoiceStatus.UNCOLLECTIBLE, + limit: 100 + }) + + if (uncollectibleInvoices.data.length > 0) { + logger.info(`Organization ${organization.id} has uncollectible invoices`) + return + } + + await organizationService.updateOrganization( + { + id: organization.id, + status: OrganizationStatus.ACTIVE, + updatedBy: organization.createdBy // Use the organization's creator as updater + }, + queryRunner, + true // fromStripe = true to allow status updates + ) + + // Always update cache with latest subscription data when invoice is paid + // This ensures access is provisioned for plan upgrades even if org is already active + const stripeManager = await StripeManager.getInstance() + const cacheManager = await UsageCacheManager.getInstance() + + // Refetch subscription after potential resume to get updated status + const updatedSubscription = await this.stripe.subscriptions.retrieve(subscriptionId) + const currentProductId = updatedSubscription.items.data[0]?.price.product as string + + await cacheManager.updateSubscriptionDataToCache(subscriptionId, { + productId: currentProductId, + subsriptionDetails: stripeManager.getSubscriptionObject(updatedSubscription), + features: await stripeManager.getFeaturesByPlan(subscriptionId, true), + quotas: await cacheManager.getQuotas(subscriptionId, true) + }) + + logger.info(`Successfully reactivated organization ${organization.id} and updated cache for subscription ${subscriptionId}`) + } catch (error) { + logger.error(`stripe.service.reactivateOrganizationIfEligible: ${error}`) + throw error + } + } + + public async handleInvoiceMarkedUncollectible(invoice: Stripe.Invoice, queryRunner: QueryRunner): Promise { + await this.getStripe() // Initialize stripe if not already done + + if (!invoice.subscription) { + logger.warn(`No subscription ID found in invoice: ${invoice.id}`) + return + } + + const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription.id + + try { + const organization = await queryRunner.manager.findOne(Organization, { + where: { subscriptionId } + }) + + if (!organization) { + logger.warn(`No organization found for subscription ID: ${subscriptionId}`) + return + } + + // Set organization status to suspended + const organizationService = new OrganizationService() + await organizationService.updateOrganization( + { + id: organization.id, + status: OrganizationStatus.PAST_DUE, + updatedBy: organization.createdBy // Use the organization's creator as updater + }, + queryRunner, + true // fromStripe = true to allow status updates + ) + + // Update lastLogin for workspace users in Default Workspace + await this.updateLastLoginForDefaultWorkspaceUsers(organization.id, queryRunner) + } catch (error) { + logger.error(`Error handling invoice marked uncollectible: ${error}`) + await queryRunner.rollbackTransaction() + throw error + } finally { + await queryRunner.release() + } + } + + private async updateLastLoginForDefaultWorkspaceUsers(organizationId: string, queryRunner: QueryRunner): Promise { + try { + // Get all organization users for the suspended organization + const organizationUsers = await queryRunner.manager.find(OrganizationUser, { + where: { organizationId } + }) + + if (organizationUsers.length === 0) { + return + } + + const userIds = organizationUsers.map((ou) => ou.userId) + + // Find workspaces named "Default Workspace" for this organization + const defaultWorkspaces = await queryRunner.manager.find(Workspace, { + where: { + organizationId, + name: WorkspaceName.DEFAULT_WORKSPACE + } + }) + + if (defaultWorkspaces.length === 0) { + return + } + + const workspaceIds = defaultWorkspaces.map((w) => w.id) + + // Find workspace users for these users in Default Workspaces + const workspaceUsers = await queryRunner.manager + .createQueryBuilder(WorkspaceUser, 'wu') + .where('wu.userId IN (:...userIds)', { userIds }) + .andWhere('wu.workspaceId IN (:...workspaceIds)', { workspaceIds }) + .getMany() + + if (workspaceUsers.length === 0) { + return + } + + // Update lastLogin for all found workspace users + const currentTimestamp = new Date().toISOString() + + await queryRunner.manager + .createQueryBuilder() + .update(WorkspaceUser) + .set({ lastLogin: currentTimestamp }) + .where('userId IN (:...userIds)', { userIds }) + .andWhere('workspaceId IN (:...workspaceIds)', { workspaceIds }) + .execute() + } catch (error) { + logger.error(`Error updating lastLogin for Default Workspace users: ${error}`, { + organizationId + }) + // Don't throw - this is not critical enough to fail the suspension + } + } +} diff --git a/packages/server/src/enterprise/webhooks/stripe.ts b/packages/server/src/enterprise/webhooks/stripe.ts new file mode 100644 index 00000000000..cf0e275da07 --- /dev/null +++ b/packages/server/src/enterprise/webhooks/stripe.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express' +import Stripe from 'stripe' +import { StripeManager } from '../../StripeManager' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import logger from '../../utils/logger' +import { StripeService } from '../services/stripe.service' + +export class StripeWebhooks { + private stripe: Stripe + + public handler = async (req: Request, res: Response) => { + const stripeManager = await StripeManager.getInstance() + this.stripe = stripeManager.getStripe() + + let queryRunner + try { + queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() + await queryRunner.connect() + + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET + + if (!endpointSecret) { + return res.status(400).json({ error: 'Webhook secret not configured' }) + } + + const sig = req.headers['stripe-signature'] + let event: Stripe.Event + + try { + event = this.stripe.webhooks.constructEvent(req.body, sig as string, endpointSecret) + } catch (err) { + logger.error(`Webhook signature verification failed: ${err}`) + return res.status(400).json({ error: 'Invalid signature' }) + } + + switch (event.type) { + case 'invoice.paid': { + const stripeService = new StripeService() + await stripeService.reactivateOrganizationIfEligible(event.data.object as Stripe.Invoice, queryRunner) + break + } + + case 'invoice.marked_uncollectible': { + const stripeService = new StripeService() + await stripeService.handleInvoiceMarkedUncollectible(event.data.object as Stripe.Invoice, queryRunner) + break + } + } + + res.status(200).json({ received: true }) + } catch (error) { + if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction() + } finally { + if (queryRunner) await queryRunner.release() + } + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 26d06c8f31c..737bff2847c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,10 +31,11 @@ import { RedisEventSubscriber } from './queue/RedisEventSubscriber' import 'global-agent/bootstrap' import { UsageCacheManager } from './UsageCacheManager' import { Workspace } from './enterprise/database/entities/workspace.entity' -import { Organization } from './enterprise/database/entities/organization.entity' +import { Organization, OrganizationStatus } from './enterprise/database/entities/organization.entity' import { GeneralRole, Role } from './enterprise/database/entities/role.entity' import { migrateApiKeysFromJsonToDb } from './utils/apiKey' import { ExpressAdapter } from '@bull-board/express' +import { StripeWebhooks } from './enterprise/webhooks/stripe' declare global { namespace Express { @@ -157,6 +158,10 @@ export class App { } async config() { + // Add Stripe webhook route BEFORE global JSON middleware to preserve raw body + const stripeWebhooks = new StripeWebhooks() + this.app.post('/api/v1/webhooks/stripe', express.raw({ type: 'application/json' }), stripeWebhooks.handler) + // Limit is needed to allow sending/receiving base64 encoded string const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb' this.app.use(express.json({ limit: flowise_file_size_limit })) @@ -251,6 +256,10 @@ export class App { if (!org) { return res.status(401).json({ error: 'Unauthorized Access' }) } + if (org.status == OrganizationStatus.PAST_DUE) + return res.status(402).json({ error: 'Access denied. Your organization has past due payments.' }) + if (org.status == OrganizationStatus.UNDER_REVIEW) + return res.status(403).json({ error: 'Access denied. Your organization is under review.' }) const subscriptionId = org.subscriptionId as string const customerId = org.customerId as string const features = await this.identityManager.getFeaturesByPlan(subscriptionId) diff --git a/packages/server/src/utils/buildChatflow.ts b/packages/server/src/utils/buildChatflow.ts index 195c54b973f..55d6d37165f 100644 --- a/packages/server/src/utils/buildChatflow.ts +++ b/packages/server/src/utils/buildChatflow.ts @@ -68,7 +68,7 @@ import { getWorkspaceSearchOptions } from '../enterprise/utils/ControllerService import { OMIT_QUEUE_JOB_DATA } from './constants' import { executeAgentFlow } from './buildAgentflow' import { Workspace } from '../enterprise/database/entities/workspace.entity' -import { Organization } from '../enterprise/database/entities/organization.entity' +import { Organization, OrganizationStatus } from '../enterprise/database/entities/organization.entity' /* * Initialize the ending node to be executed @@ -949,6 +949,10 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals const orgId = org.id const subscriptionId = org.subscriptionId as string + if (org.status === OrganizationStatus.PAST_DUE) { + throw new InternalFlowiseError(StatusCodes.PAYMENT_REQUIRED, 'Organization suspended due to non-payment') + } + await checkPredictions(orgId, subscriptionId, appServer.usageCacheManager) const executeData: IExecuteFlowParams = { diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index 52cfa4023f4..ccec702c6d5 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -39,6 +39,7 @@ export const WHITELIST_URLS = [ '/api/v1/loginmethod', '/api/v1/pricing', '/api/v1/user/test', + '/api/v1/webhooks', '/api/v1/oauth2-credential/callback', '/api/v1/oauth2-credential/refresh', AzureSSO.LOGIN_URI, diff --git a/packages/ui/src/api/organization.js b/packages/ui/src/api/organization.js new file mode 100644 index 00000000000..6634bc70da9 --- /dev/null +++ b/packages/ui/src/api/organization.js @@ -0,0 +1,7 @@ +import client from './client' + +const getOrganizationById = (id) => client.get(`/organization?id=${id}`) + +export default { + getOrganizationById +} diff --git a/packages/ui/src/api/user.js b/packages/ui/src/api/user.js index 86165ec9ca7..cc5ff29929e 100644 --- a/packages/ui/src/api/user.js +++ b/packages/ui/src/api/user.js @@ -18,8 +18,8 @@ const getAdditionalSeatsQuantity = (subscriptionId) => const getCustomerDefaultSource = (customerId) => client.get(`/organization/customer-default-source?customerId=${customerId}`) const getAdditionalSeatsProration = (subscriptionId, quantity) => client.get(`/organization/additional-seats-proration?subscriptionId=${subscriptionId}&quantity=${quantity}`) -const updateAdditionalSeats = (subscriptionId, quantity, prorationDate) => - client.post(`/organization/update-additional-seats`, { subscriptionId, quantity, prorationDate }) +const updateAdditionalSeats = (subscriptionId, quantity, prorationDate, increase) => + client.post(`/organization/update-additional-seats`, { subscriptionId, quantity, prorationDate, increase }) const getPlanProration = (subscriptionId, newPlanId) => client.get(`/organization/plan-proration?subscriptionId=${subscriptionId}&newPlanId=${newPlanId}`) const updateSubscriptionPlan = (subscriptionId, newPlanId, prorationDate) => diff --git a/packages/ui/src/api/workspace-user.api.js b/packages/ui/src/api/workspace-user.api.js new file mode 100644 index 00000000000..23cb97eafcb --- /dev/null +++ b/packages/ui/src/api/workspace-user.api.js @@ -0,0 +1,6 @@ +import client from './client' + +const getWorkspaceByUserId = (userId) => client.get(`/workspaceuser?userId=${userId}`) +export default { + getWorkspaceByUserId +} diff --git a/packages/ui/src/hooks/useApi.jsx b/packages/ui/src/hooks/useApi.jsx index 58f8bf09cd9..375344cda38 100644 --- a/packages/ui/src/hooks/useApi.jsx +++ b/packages/ui/src/hooks/useApi.jsx @@ -14,9 +14,11 @@ export default (apiFunc) => { setData(result.data) setError(null) setApiError(null) + return result // Return the full response for payment failure handling } catch (err) { handleError(err || 'Unexpected Error!') setApiError(err || 'Unexpected Error!') + throw err // Re-throw error to maintain existing error handling } finally { setLoading(false) } diff --git a/packages/ui/src/layout/MainLayout/index.jsx b/packages/ui/src/layout/MainLayout/index.jsx index cab73c15a83..9c0fd843ae5 100644 --- a/packages/ui/src/layout/MainLayout/index.jsx +++ b/packages/ui/src/layout/MainLayout/index.jsx @@ -11,6 +11,14 @@ import Header from './Header' import Sidebar from './Sidebar' import { drawerWidth, headerHeight } from '@/store/constant' import { SET_MENU } from '@/store/actions' +import { store } from '@/store' +import { organizationUpdated } from '@/store/reducers/authSlice' + +// hooks +import useApi from '@/hooks/useApi' + +// api +import organizationApi from '@/api/organization' // styles const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ @@ -60,6 +68,10 @@ const MainLayout = () => { const theme = useTheme() const matchDownMd = useMediaQuery(theme.breakpoints.down('lg')) + // authenticated user + const user = useSelector((state) => state.auth.user) + const isAuthenticated = useSelector((state) => state.auth.isAuthenticated) + // Handle left drawer const leftDrawerOpened = useSelector((state) => state.customization.opened) const dispatch = useDispatch() @@ -67,6 +79,24 @@ const MainLayout = () => { dispatch({ type: SET_MENU, opened: !leftDrawerOpened }) } + const getOrganizationsByIdApi = useApi(organizationApi.getOrganizationById) + + useEffect(() => { + if (isAuthenticated && user) { + getOrganizationsByIdApi.request(user.activeOrganizationId) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, user]) + + useEffect(() => { + if (getOrganizationsByIdApi.data) { + store.dispatch(organizationUpdated(getOrganizationsByIdApi.data)) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getOrganizationsByIdApi.data]) + useEffect(() => { setTimeout(() => dispatch({ type: SET_MENU, opened: !matchDownMd }), 0) // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/ui/src/layout/MinimalLayout/index.jsx b/packages/ui/src/layout/MinimalLayout/index.jsx index 0982616fdff..1b8120c44ec 100644 --- a/packages/ui/src/layout/MinimalLayout/index.jsx +++ b/packages/ui/src/layout/MinimalLayout/index.jsx @@ -1,11 +1,46 @@ +import { useEffect } from 'react' import { Outlet } from 'react-router-dom' +import { useSelector } from 'react-redux' + +import { store } from '@/store' +import { organizationUpdated } from '@/store/reducers/authSlice' + +// hooks +import useApi from '@/hooks/useApi' + +// api +import organizationApi from '@/api/organization' // ==============================|| MINIMAL LAYOUT ||============================== // -const MinimalLayout = () => ( - <> - - -) +const MinimalLayout = () => { + // authenticated user + const user = useSelector((state) => state.auth.user) + const isAuthenticated = useSelector((state) => state.auth.isAuthenticated) + + const getOrganizationsByIdApi = useApi(organizationApi.getOrganizationById) + + useEffect(() => { + if (isAuthenticated && user) { + getOrganizationsByIdApi.request(user.activeOrganizationId) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, user]) + + useEffect(() => { + if (getOrganizationsByIdApi.data) { + store.dispatch(organizationUpdated(getOrganizationsByIdApi.data)) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getOrganizationsByIdApi.data]) + + return ( + <> + + + ) +} export default MinimalLayout diff --git a/packages/ui/src/routes/RequireAuth.jsx b/packages/ui/src/routes/RequireAuth.jsx index d9694d67408..72c15f019a6 100644 --- a/packages/ui/src/routes/RequireAuth.jsx +++ b/packages/ui/src/routes/RequireAuth.jsx @@ -1,9 +1,30 @@ +import { useEffect, useState } from 'react' import { Navigate } from 'react-router' import PropTypes from 'prop-types' import { useLocation } from 'react-router-dom' import { useConfig } from '@/store/context/ConfigContext' import { useAuth } from '@/hooks/useAuth' -import { useSelector } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' +import useNotifier from '@/utils/useNotifier' +import { useNavigate } from 'react-router-dom' + +import { enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions' + +// material-ui +import { Button, Dialog, DialogContent, Typography, Stack, DialogActions, CircularProgress, Box } from '@mui/material' +import { IconExternalLink, IconCreditCard, IconLogout, IconX } from '@tabler/icons-react' + +// API +import accountApi from '@/api/account.api' +import workspaceUserApi from '@/api/workspace-user.api' +import workspaceApi from '@/api/workspace' + +// Hooks +import useApi from '@/hooks/useApi' + +// store +import { store } from '@/store' +import { logoutSuccess, workspaceSwitchSuccess } from '@/store/reducers/authSlice' /** * Checks if a feature flag is enabled @@ -28,13 +49,117 @@ const checkFeatureFlag = (features, display, children) => { } export const RequireAuth = ({ permission, display, children }) => { + const navigate = useNavigate() const location = useLocation() + const dispatch = useDispatch() const { isCloud, isOpenSource, isEnterpriseLicensed } = useConfig() const { hasPermission } = useAuth() const isGlobal = useSelector((state) => state.auth.isGlobal) const currentUser = useSelector((state) => state.auth.user) const features = useSelector((state) => state.auth.features) const permissions = useSelector((state) => state.auth.permissions) + const organization = useSelector((state) => state.auth.organization) + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + + const logoutApi = useApi(accountApi.logout) + const getWorkspaceByUserIdApi = useApi(workspaceUserApi.getWorkspaceByUserId) + const switchWorkspaceApi = useApi(workspaceApi.switchWorkspace) + + const [showOrgPastDueDialog, setShowOrgPastDueDialog] = useState(false) + const [isBillingLoading, setIsBillingLoading] = useState(false) + + useEffect(() => { + if (organization && organization.status === 'past_due') { + if (currentUser && currentUser.isOrganizationAdmin === false) { + handleSwitchWorkspace(currentUser) + } else { + setShowOrgPastDueDialog(true) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organization, currentUser]) + + const handleBillingPortalClick = async () => { + setIsBillingLoading(true) + try { + const resp = await accountApi.getBillingData() + if (resp.data?.url) { + window.open(resp.data.url, '_blank') + } + } catch (error) { + enqueueSnackbar({ + message: 'Failed to access billing portal', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error' + } + }) + } finally { + setIsBillingLoading(false) + } + } + + const handleLogout = () => { + logoutApi.request() + enqueueSnackbar({ + message: 'Logging out...', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } + + const handleSwitchWorkspace = async (currentUser) => { + try { + const resp = await getWorkspaceByUserIdApi.request(currentUser.id) + const workspaceIds = resp.data.filter((item) => item.isOrgOwner).map((item) => item.workspaceId) + switchWorkspaceApi.request(workspaceIds[0]) + enqueueSnackbar({ + message: 'Switched to your own workspace', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success' + } + }) + } catch (error) { + enqueueSnackbar({ + message: 'Failed to handleSwitchWorkspace', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error' + } + }) + } + } + + useEffect(() => { + if (switchWorkspaceApi.data) { + store.dispatch(workspaceSwitchSuccess(switchWorkspaceApi.data)) + + // get the current path and navigate to the same after refresh + navigate('/', { replace: true }) + navigate(0) + } + }, [switchWorkspaceApi.data, navigate]) + + useEffect(() => { + try { + if (logoutApi.data && logoutApi.data.message === 'logged_out') { + store.dispatch(logoutSuccess()) + window.location.href = logoutApi.data.redirectTo + } + } catch (e) { + console.error(e) + } + }, [logoutApi.data]) // Step 1: Authentication Check // Redirect to login if user is not authenticated @@ -50,6 +175,96 @@ export const RequireAuth = ({ permission, display, children }) => { // Cloud & Enterprise: Check both permissions and feature flags if (isCloud || isEnterpriseLicensed) { + if (isCloud) { + return ( + <> + {children} + + + + + + + + Account Under Suspension + + + + + Your account has been suspended due to a failed payment renewal. To restore access to your account, + please update your payment method and pay any outstanding invoices. + + + + Click the button below to access your billing portal where you can: + + + + + • Update your payment method + + + • Pay outstanding invoices + + + • View your billing history + + + + + + + + + + + + If you think that this is a bug, please report it to us at{' '} + + support@flowiseai.com + + + + + + ) + } + // Allow access to basic features (no display property) if (!display) return children diff --git a/packages/ui/src/store/reducers/authSlice.js b/packages/ui/src/store/reducers/authSlice.js index 6397949cf56..79d6b2461af 100644 --- a/packages/ui/src/store/reducers/authSlice.js +++ b/packages/ui/src/store/reducers/authSlice.js @@ -59,10 +59,21 @@ const authSlice = createSlice({ }) state.user.assignedWorkspaces = assignedWorkspaces AuthUtils.updateCurrentUser(state.user) + }, + organizationUpdated: (state, action) => { + const organization = action.payload + state.organization = organization } } }) -export const { loginSuccess, logoutSuccess, workspaceSwitchSuccess, upgradePlanSuccess, userProfileUpdated, workspaceNameUpdated } = - authSlice.actions +export const { + loginSuccess, + logoutSuccess, + workspaceSwitchSuccess, + upgradePlanSuccess, + userProfileUpdated, + workspaceNameUpdated, + organizationUpdated +} = authSlice.actions export default authSlice.reducer diff --git a/packages/ui/src/ui-component/subscription/PricingDialog.jsx b/packages/ui/src/ui-component/subscription/PricingDialog.jsx index 7f95f2d7c43..153acd192ee 100644 --- a/packages/ui/src/ui-component/subscription/PricingDialog.jsx +++ b/packages/ui/src/ui-component/subscription/PricingDialog.jsx @@ -88,23 +88,53 @@ const PricingDialog = ({ open, onClose }) => { prorationInfo.prorationDate ) if (response.data.status === 'success') { - // Subscription updated successfully - store.dispatch(upgradePlanSuccess(response.data.user)) - enqueueSnackbar('Subscription updated successfully!', { variant: 'success' }) - onClose(true) + // Check if payment failed but plan was upgraded (Issue #4 fix) + if (response.data.paymentFailed) { + // Subscription updated but payment failed + store.dispatch(upgradePlanSuccess(response.data.user)) + const paymentErrorMessage = response.data.paymentError || 'Payment failed' + enqueueSnackbar( + `Plan upgraded successfully! However, your payment failed (${paymentErrorMessage}). We'll retry for the next few days. Please update your payment method or your account may be suspended.`, + { + variant: 'error', + autoHideDuration: 8000 + } + ) + // Delay closing to allow user to see the warning message + setTimeout(() => { + setOpenPlanDialog(false) + onClose(true) + }, 8000) + } else { + // Subscription updated successfully with no payment issues + store.dispatch(upgradePlanSuccess(response.data.user)) + enqueueSnackbar('Subscription updated successfully!', { variant: 'success' }) + // Delay closing to allow user to see the success message + setTimeout(() => { + setOpenPlanDialog(false) + onClose(true) + }, 3000) + } } else { const errorMessage = response.data.message || 'Subscription failed to update' enqueueSnackbar(errorMessage, { variant: 'error' }) - onClose() + // Delay closing to allow user to see the error message + setTimeout(() => { + setOpenPlanDialog(false) + onClose() + }, 3000) } } catch (error) { console.error('Error updating plan:', error) - const errorMessage = err.response?.data?.message || 'Failed to verify subscription' + const errorMessage = error.response?.data?.message || 'Failed to update subscription' enqueueSnackbar(errorMessage, { variant: 'error' }) - onClose() + // Delay closing to allow user to see the error message + setTimeout(() => { + setOpenPlanDialog(false) + onClose() + }, 3000) } finally { setIsUpdatingPlan(false) - setOpenPlanDialog(false) } } @@ -523,8 +553,8 @@ const PricingDialog = ({ open, onClose }) => { { } } - const handleSeatsModification = async (newSeatsAmount) => { + const handleSeatsModification = async (newSeatsAmount, increase) => { try { setIsUpdatingSeats(true) @@ -355,23 +355,45 @@ const AccountSettings = () => { throw new Error('No proration date available') } - await updateAdditionalSeatsApi.request( + const response = await updateAdditionalSeatsApi.request( currentUser?.activeOrganizationSubscriptionId, newSeatsAmount, - prorationInfo.prorationDate + prorationInfo.prorationDate, + increase ) - enqueueSnackbar({ - message: 'Seats updated successfully', - options: { - key: new Date().getTime() + Math.random(), - variant: 'success', - action: (key) => ( - - ) - } - }) + + // Check if payment failed but seats were updated (Issue #4 fix) + if (response.data?.paymentFailed) { + // Seats updated but payment failed + const paymentErrorMessage = response.data.paymentError || 'Payment failed' + enqueueSnackbar({ + message: `Seats updated successfully! However, your payment failed (${paymentErrorMessage}). We'll retry for the next few days. Please update your payment method or your account may be suspended.`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'warning', + persist: true, + action: (key) => ( + + ) + } + }) + } else { + // Seats updated successfully with no payment issues + enqueueSnackbar({ + message: 'Seats updated successfully', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } // Refresh the seats quantity display getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId) } catch (error) { @@ -1160,7 +1182,7 @@ const AccountSettings = () => {