Compare commits

...

40 Commits

Author SHA1 Message Date
Ong Chung Yau 370c55aa78
Merge branch 'main' into fix/stripe-issues 2025-07-14 22:26:37 +08:00
chungyau97 99f7f7dc6d fix: incorrect additional seats quantity of mix invoices 2025-07-14 22:08:00 +08:00
Ong Chung Yau 784b98a818
Merge branch 'main' into fix/stripe-issues 2025-07-14 20:45:39 +08:00
chungyau97 268763ccee feat: handle invoices with both paid and unpaid items in updateAdditionalSeats 2025-07-14 17:43:14 +08:00
chungyau97 7150c5434d fix: void incorrect invoice in updateSubscriptionPlan 2025-07-14 14:53:08 +08:00
Ong Chung Yau 77f738ebb4
Merge branch 'main' into fix/stripe-issues 2025-07-14 11:16:43 +08:00
chungyau97 9ae54bc921 feat: not allow to add seats when there're unsuccessful additional seat payment 2025-07-12 02:26:37 +08:00
chungyau97 ba71c2975e Merge branch 'main' into fix/stripe-issues 2025-07-11 14:57:15 +08:00
chungyau97 46dc4324b6 fix: member always get redirect to own workspace 2025-07-10 17:49:10 +08:00
Ong Chung Yau da8623d8aa
Merge branch 'main' into fix/stripe-issues 2025-07-10 16:13:11 +08:00
Ong Chung Yau deae7d9aff
feat: switch member to own workspace after switching to past-due org
* chore: SubscriptionStatus is defined but never used

* fix: show orgPastDueDialog when member or owner is on a past-due organization's workspaces

* feat: switch  member to own workspace after switching to past-due org
2025-07-10 16:07:34 +08:00
Ong Chung Yau c74e1750f3
Merge branch 'main' into fix/stripe-issues 2025-07-09 17:17:05 +08:00
Ong Chung Yau 56c51ac7b5
fix: list invoice with invalid status
fix: list invoice with invalid status
2025-07-08 20:27:58 +08:00
Ilango cc6931ecfe Only show org suspended dialog to org admins 2025-07-08 15:33:36 +05:30
Ilango 18d2e0f7e6 Show errors in payment when upgrading plans or purchasing additional seats 2025-07-08 15:17:52 +05:30
Ilango 5e25ce5dd4 Fix issues in stripe 2025-07-07 15:17:43 +05:30
Ilango 611b312672 Add logout button and contact support link in account suspended dialog 2025-07-07 15:17:24 +05:30
Ilango fda7ca5523 Add logout and contact support button to org suspended dialog 2025-07-04 13:57:17 +05:30
Ilango 3b1c79f053 Merge branch 'main' of github.com:FlowiseAI/Flowise into fix/stripe-issues 2025-07-03 14:45:23 +05:30
Ilango fb64ea7918 Merge branch 'main' of github.com:FlowiseAI/Flowise into fix/stripe-issues 2025-06-27 15:00:51 +05:30
Ilango 6ace6617fa Fix issues and code cleanup 2025-06-27 14:59:39 +05:30
Ilango 324868a021 Fix issue with upgrading with invalid payment method 2025-06-24 17:54:59 +05:30
Ilango 6d39b83c51 Fix issues with downgrading to free plan 2025-06-19 15:12:48 +05:30
Ilango 764cc6c144 Fix first month free callout styles in dark mode 2025-06-18 15:18:54 +05:30
Ilango 36c872f2de Fix issue with updating last login for org members when subscription renewal fails 2025-06-18 14:29:11 +05:30
Ilango 9a41effa93 Fix issue with subscription not resuming after paying uncollectible invoice 2025-06-17 15:26:40 +05:30
Ilango 2f87f649cd Lint fix 2025-06-16 17:36:58 +05:30
Ilango d1f71ce433 Update behavior for invoice.paid event based on organization status 2025-06-16 17:34:36 +05:30
Ilango 52e2dab190 Fix org status check when receiving invoice.paid event from stripe 2025-06-16 13:55:58 +05:30
Ilango 81c8d42828 Merge branch 'fix/stripe-issues' of github.com:FlowiseAI/Flowise into fix/stripe-issues 2025-06-16 11:33:46 +05:30
Ilango e596c4aecd Show dialog in UI for suspended organization 2025-06-16 11:33:39 +05:30
Ilango cbcda5538d Merge branch 'main' of github.com:FlowiseAI/Flowise into fix/stripe-issues 2025-06-16 10:48:26 +05:30
Ong Chung Yau 4a2ea0a425
Enforce restrictions based on organization.status (#4652)
* feat: does not allow change of organziation.status unless from stripe

* feat: restrict apikey when organization.status is not active
2025-06-13 21:19:07 +08:00
Ilango 407c8bb1a8 Merge branch 'fix/stripe-issues' of github.com:FlowiseAI/Flowise into fix/stripe-issues 2025-06-13 13:43:36 +05:30
Ilango aaf2f6eb19 Update behavior for stripe webhooks 2025-06-13 13:43:20 +05:30
Ong Chung Yau 260219e94d
feature/organization-status (#4637)
feat: add status column in organization
2025-06-12 18:33:05 +08:00
Ilango 32ade38c06 Fix merge conflict 2025-06-12 14:54:40 +05:30
Ilango 7d3c070714 Refactor stripe webhooks handler 2025-06-12 14:50:25 +05:30
Ilango 71cbe601ee Remove unnecessary readme file 2025-06-05 11:51:55 +05:30
Ilango 4d52643621 Add stripe webhook for handling payment failures 2025-06-05 11:49:43 +05:30
28 changed files with 1159 additions and 187 deletions

View File

@ -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
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()
// Extract quotas from metadata
const quotas: Record<string, number> = {}
for (const key in productMetadata) {
if (key.startsWith('quota:')) {
quotas[key] = parseInt(productMetadata[key])
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()
}
}
// 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<string, number> = {}
for (const key in productMetadata) {
if (key.startsWith('quota:')) {
quotas[key] = parseInt(productMetadata[key])
}
}
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
// Get features from Stripe
const features = await this.getFeaturesByPlan(subscription.id, true)
// Update the cache with new subscription data including quotas
const cacheManager = await UsageCacheManager.getInstance()
const updateCacheData: Record<string, any> = {
features,
quotas,
subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription)
}
if (
newPlanId === process.env.CLOUD_FREE_ID ||
newPlanId === process.env.CLOUD_STARTER_ID ||
newPlanId === process.env.CLOUD_PRO_ID
) {
updateCacheData.productId = newPlanId
}
await cacheManager.updateSubscriptionDataToCache(subscriptionId, updateCacheData)
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
) {
loggedInUser.activeOrganizationProductId = newPlanId
}
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
}
}
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
// Get features from Stripe
const features = await this.getFeaturesByPlan(subscription.id, true)
// Update the cache with new subscription data including quotas
const cacheManager = await UsageCacheManager.getInstance()
const updateCacheData: Record<string, any> = {
features,
quotas,
subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription)
}
if (
newPlanId === process.env.CLOUD_FREE_ID ||
newPlanId === process.env.CLOUD_STARTER_ID ||
newPlanId === process.env.CLOUD_PRO_ID
) {
updateCacheData.productId = newPlanId
}
await cacheManager.updateSubscriptionDataToCache(subscriptionId, updateCacheData)
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
) {
loggedInUser.activeOrganizationProductId = newPlanId
}
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
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
await queryRunner.query(`alter table \`organization\` add \`status\` varchar(20) default "${OrganizationStatus.ACTIVE}" not null ;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter table \`organization\` drop column \`status\` ;`)
}
}

View File

@ -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<void> {
await queryRunner.query(`alter table \`organization\` add \`status\` varchar(20) default "${OrganizationStatus.ACTIVE}" not null ;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter table \`organization\` drop column \`status\` ;`)
}
}

View File

@ -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<void> {
queryRunner.query(`alter table "organization" add column "status" varchar(20) default '${OrganizationStatus.ACTIVE}' not null;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
queryRunner.query(`alter table "organization" drop column "status";`)
}
}

View File

@ -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<void> {
queryRunner.query(`alter table "organization" add column "status" varchar(20) default '${OrganizationStatus.ACTIVE}' not null;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
queryRunner.query(`alter table "organization" drop column "status";`)
}
}

View File

@ -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<Organization>, 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<Organization>) {
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
public async updateOrganization(newOrganizationData: Partial<Organization>, 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
}

View File

@ -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<Stripe> {
if (!this.stripe) {
const stripeManager = await StripeManager.getInstance()
this.stripe = stripeManager.getStripe()
}
return this.stripe
}
public async reactivateOrganizationIfEligible(invoice: Stripe.Invoice, queryRunner: QueryRunner): Promise<void> {
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<void> {
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<void> {
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import client from './client'
const getOrganizationById = (id) => client.get(`/organization?id=${id}`)
export default {
getOrganizationById
}

View File

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

View File

@ -0,0 +1,6 @@
import client from './client'
const getWorkspaceByUserId = (userId) => client.get(`/workspaceuser?userId=${userId}`)
export default {
getWorkspaceByUserId
}

View File

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

View File

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

View File

@ -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 = () => (
<>
<Outlet />
</>
)
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 (
<>
<Outlet />
</>
)
}
export default MinimalLayout

View File

@ -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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
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}
<Dialog
open={showOrgPastDueDialog}
disableEscapeKeyDown
disableBackdropClick
PaperProps={{
style: {
padding: '20px',
minWidth: '500px'
}
}}
>
<DialogContent>
<Stack spacing={3}>
<Stack spacing={1} alignItems='center' textAlign='center'>
<IconCreditCard size={48} color='#f44336' />
<Typography variant='h3' color='error'>
Account Under Suspension
</Typography>
</Stack>
<Typography variant='body1' color='text.secondary' textAlign='center'>
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.
</Typography>
<Typography variant='body2' color='text.secondary' textAlign='center'>
Click the button below to access your billing portal where you can:
</Typography>
<Stack spacing={1} sx={{ pl: 2 }}>
<Typography variant='body2' color='text.secondary'>
Update your payment method
</Typography>
<Typography variant='body2' color='text.secondary'>
Pay outstanding invoices
</Typography>
<Typography variant='body2' color='text.secondary'>
View your billing history
</Typography>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack sx={{ width: '100%', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Button
variant='outlined'
color='error'
onClick={handleLogout}
startIcon={<IconLogout />}
fullWidth
sx={{
borderRadius: 2,
height: 48
}}
>
Logout
</Button>
<Button
variant='contained'
color='primary'
onClick={handleBillingPortalClick}
disabled={isBillingLoading}
startIcon={isBillingLoading ? <CircularProgress size={20} /> : <IconExternalLink />}
fullWidth
sx={{
borderRadius: 2,
height: 48
}}
>
{isBillingLoading ? 'Opening Billing Portal...' : 'Go to Billing Portal'}
</Button>
</Stack>
<Box sx={{ width: '100%' }}>
If you think that this is a bug, please report it to us at{' '}
<a href='mailto:support@flowiseai.com' rel='noopener noreferrer' target='_blank'>
support@flowiseai.com
</a>
</Box>
</DialogActions>
</Dialog>
</>
)
}
// Allow access to basic features (no display property)
if (!display) return children

View File

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

View File

@ -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 }) => {
<Box
sx={{
p: 1.5,
bgcolor: 'warning.light',
color: 'warning.dark',
bgcolor: customization.isDarkMode ? 'success.dark' : 'success.light',
color: customization.isDarkMode ? 'success.light' : 'success.dark',
borderRadius: 1,
display: 'flex',
alignItems: 'center',

View File

@ -347,7 +347,7 @@ const AccountSettings = () => {
}
}
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
// 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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
} else {
// Seats updated successfully with no payment issues
enqueueSnackbar({
message: 'Seats updated successfully',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
// Refresh the seats quantity display
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
} catch (error) {
@ -1160,7 +1182,7 @@ const AccountSettings = () => {
</Button>
<Button
variant='outlined'
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity)}
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity, false)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||
@ -1414,7 +1436,7 @@ const AccountSettings = () => {
</Button>
<Button
variant='contained'
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats)}
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats, true)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||