Compare commits

...

51 Commits

Author SHA1 Message Date
Ilango Rajagopal 20441422b3 Assign priority when assigning credits 2025-08-05 15:34:37 +05:30
Ilango Rajagopal 794ddbc71b Add more credits packages 2025-08-05 15:03:42 +05:30
Ilango Rajagopal ddfecf725c Store credits purchased and used in Redis instead of reading/writing to Stripe 2025-08-04 15:02:25 +05:30
Ilango e612716c7c Fix issues with tracking predictions usage when consuming credits 2025-08-01 15:04:32 +05:30
Ilango 66167761e0 Fix conflicts 2025-07-21 14:43:54 +05:30
Ilango bb77d7e792 Remove setup document 2025-07-21 14:35:48 +05:30
Ilango dbf5a2edc5 Fix issues when purchasing credits 2025-07-21 14:32:08 +05:30
Ilango 051d8ef401 Add UI for credits 2025-07-18 15:56:50 +05:30
Ilango 871cf86925 Fix errors due to stripe sdk version upgrade 2025-07-18 15:44:14 +05:30
Ilango 79195ee8b1 Allow users to purchase additional credits for predictions after exhausing plan limits 2025-07-18 15:03:19 +05:30
Ilango 790a762ddb Merge branch 'main' of github.com:FlowiseAI/Flowise into feature/surcharge-pricing 2025-07-17 15:02:57 +05:30
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
34 changed files with 40618 additions and 38427 deletions

View File

@ -142,7 +142,7 @@
"s3-streamlogger": "^1.11.0",
"sanitize-html": "^2.11.0",
"sqlite3": "^5.1.6",
"stripe": "^15.6.0",
"stripe": "^18.3.0",
"turndown": "^7.2.0",
"typeorm": "^0.3.6",
"uuid": "^9.0.1",

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) {
@ -370,6 +382,45 @@ export class IdentityManager {
return await this.stripeManager.getPlanProration(subscriptionId, newPlanId)
}
public async checkPredictionEligibility(orgId: string, subscriptionId: string) {
if (!orgId || !subscriptionId) return {}
if (!this.stripeManager) {
throw new Error('Stripe manager is not initialized')
}
return await this.stripeManager.checkPredictionEligibility(orgId, subscriptionId)
}
public async purchaseCredits(subscriptionId: string, packageType: string) {
if (!subscriptionId || !packageType) return {}
if (!this.stripeManager) {
throw new Error('Stripe manager is not initialized')
}
return await this.stripeManager.purchaseCredits(subscriptionId, packageType)
}
public async getCreditBalance(subscriptionId: string) {
if (!subscriptionId) return {}
if (!this.stripeManager) {
throw new Error('Stripe manager is not initialized')
}
return await this.stripeManager.getCreditBalance(subscriptionId)
}
public async getUsageWithCredits(orgId: string, subscriptionId: string) {
if (!orgId || !subscriptionId) return {}
if (!this.stripeManager) {
throw new Error('Stripe manager is not initialized')
}
return await this.stripeManager.getUsageWithCredits(orgId, subscriptionId)
}
public async getCreditsPackages() {
if (!this.stripeManager) {
throw new Error('Stripe manager is not initialized')
}
return await this.stripeManager.getCreditsPackages()
}
public async updateSubscriptionPlan(req: Request, subscriptionId: string, newPlanId: string, prorationDate: number) {
if (!subscriptionId || !newPlanId) return {}
@ -379,85 +430,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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,21 +5,21 @@ import { MODE } from './Interface'
import { LICENSE_QUOTAS } from './utils/constants'
import { StripeManager } from './StripeManager'
const DISABLED_QUOTAS = {
const getDisabledQuotas = () => ({
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: 0,
[LICENSE_QUOTAS.STORAGE_LIMIT]: 0, // in MB
[LICENSE_QUOTAS.FLOWS_LIMIT]: 0,
[LICENSE_QUOTAS.USERS_LIMIT]: 0,
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: 0
}
})
const UNLIMITED_QUOTAS = {
const getUnlimitedQuotas = () => ({
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: -1,
[LICENSE_QUOTAS.STORAGE_LIMIT]: -1,
[LICENSE_QUOTAS.FLOWS_LIMIT]: -1,
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: -1
}
})
export class UsageCacheManager {
private cache: Cache
@ -67,7 +67,7 @@ export class UsageCacheManager {
public async getSubscriptionDetails(subscriptionId: string, withoutCache: boolean = false): Promise<Record<string, any>> {
const stripeManager = await StripeManager.getInstance()
if (!stripeManager || !subscriptionId) {
return UNLIMITED_QUOTAS
return getUnlimitedQuotas()
}
// Skip cache if withoutCache is true
@ -90,7 +90,7 @@ export class UsageCacheManager {
public async getQuotas(subscriptionId: string, withoutCache: boolean = false): Promise<Record<string, number>> {
const stripeManager = await StripeManager.getInstance()
if (!stripeManager || !subscriptionId) {
return UNLIMITED_QUOTAS
return getUnlimitedQuotas()
}
// Skip cache if withoutCache is true
@ -105,7 +105,7 @@ export class UsageCacheManager {
const subscription = await stripeManager.getStripe().subscriptions.retrieve(subscriptionId)
const items = subscription.items.data
if (items.length === 0) {
return DISABLED_QUOTAS
return getDisabledQuotas()
}
const productId = items[0].price.product as string
@ -113,7 +113,7 @@ export class UsageCacheManager {
const productMetadata = product.metadata
if (!productMetadata || Object.keys(productMetadata).length === 0) {
return DISABLED_QUOTAS
return getDisabledQuotas()
}
const quotas: Record<string, number> = {}
@ -162,6 +162,51 @@ export class UsageCacheManager {
this.set(cacheKey, updatedData, 3600000) // Cache for 1 hour
}
public async getCreditDataFromCache(customerId: string) {
const cacheKey = `credits:${customerId}`
return await this.get<{
totalCredits: number
totalUsage: number
availableCredits: number
lastUpdated: number
}>(cacheKey)
}
public async updateCreditDataToCache(
customerId: string,
creditData: {
totalCredits: number
totalUsage: number
availableCredits: number
lastUpdated: number
}
) {
const cacheKey = `credits:${customerId}`
// No TTL for credit data to ensure persistence
this.set(cacheKey, creditData)
}
public async incrementCreditUsage(customerId: string, quantity: number) {
if (!customerId || quantity <= 0) {
throw new Error('Invalid customer ID or quantity')
}
const existingData = await this.getCreditDataFromCache(customerId)
if (!existingData) {
throw new Error(`No credit data found for customer ${customerId}. Please purchase credits first.`)
}
const newUsage = existingData.totalUsage + quantity
const newAvailable = Math.max(0, existingData.totalCredits - newUsage)
await this.updateCreditDataToCache(customerId, {
totalCredits: existingData.totalCredits,
totalUsage: newUsage,
availableCredits: newAvailable,
lastUpdated: Date.now()
})
}
public async get<T>(key: string): Promise<T | null> {
if (!this.cache) await this.initialize()
const value = await this.cache.get<T>(key)

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) {
@ -184,4 +193,82 @@ export class OrganizationController {
next(error)
}
}
public async getPredictionEligibility(req: Request, res: Response, next: NextFunction) {
try {
const orgId = req.user?.activeOrganizationId
const subscriptionId = req.user?.activeOrganizationSubscriptionId
if (!orgId) {
return res.status(400).json({ error: 'Organization ID is required' })
}
if (!subscriptionId) {
return res.status(400).json({ error: 'Subscription ID is required' })
}
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.checkPredictionEligibility(orgId, subscriptionId)
return res.status(StatusCodes.OK).json(result)
} catch (error) {
next(error)
}
}
public async purchaseCredits(req: Request, res: Response, next: NextFunction) {
try {
const { packageType } = req.body
const subscriptionId = req.user?.activeOrganizationSubscriptionId
if (!subscriptionId) {
return res.status(400).json({ error: 'Subscription ID is required' })
}
if (!packageType) {
return res.status(400).json({ error: 'Package type is required' })
}
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.purchaseCredits(subscriptionId, packageType)
return res.status(StatusCodes.OK).json(result)
} catch (error) {
next(error)
}
}
public async getCreditsBalance(req: Request, res: Response, next: NextFunction) {
try {
const subscriptionId = req.user?.activeOrganizationSubscriptionId
if (!subscriptionId) {
return res.status(400).json({ error: 'Subscription ID is required' })
}
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.getCreditBalance(subscriptionId)
return res.status(StatusCodes.OK).json(result)
} catch (error) {
next(error)
}
}
public async getUsageWithCredits(req: Request, res: Response, next: NextFunction) {
try {
const orgId = req.user?.activeOrganizationId
const subscriptionId = req.user?.activeOrganizationSubscriptionId
if (!orgId) {
return res.status(400).json({ error: 'Organization ID is required' })
}
if (!subscriptionId) {
return res.status(400).json({ error: 'Subscription ID is required' })
}
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.getUsageWithCredits(orgId, subscriptionId)
return res.status(StatusCodes.OK).json(result)
} catch (error) {
next(error)
}
}
public async getCreditsPackages(req: Request, res: Response, next: NextFunction) {
try {
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.getCreditsPackages()
return res.status(StatusCodes.OK).json(result)
} catch (error) {
next(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

@ -24,4 +24,14 @@ router.post('/update-subscription-plan', organizationController.updateSubscripti
router.get('/get-current-usage', organizationController.getCurrentUsage)
router.get('/prediction-eligibility', organizationController.getPredictionEligibility)
router.post('/purchase-credits', organizationController.purchaseCredits)
router.get('/credits-balance', organizationController.getCreditsBalance)
router.get('/usage-with-credits', organizationController.getUsageWithCredits)
router.get('/credits-packages', organizationController.getCreditsPackages)
export default router

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,216 @@
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
const invoiceWithSubscription = invoice as any
const subscriptionId =
typeof invoiceWithSubscription.subscription === 'string'
? invoiceWithSubscription.subscription
: invoiceWithSubscription.subscription?.id
if (!subscriptionId) {
logger.warn(`No subscription ID found in invoice: ${invoice.id}`)
return
}
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
const invoiceWithSubscription = invoice as any
const subscriptionId =
typeof invoiceWithSubscription.subscription === 'string'
? invoiceWithSubscription.subscription
: invoiceWithSubscription.subscription?.id
if (!subscriptionId) {
logger.warn(`No subscription ID found in invoice: ${invoice.id}`)
return
}
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

@ -60,7 +60,14 @@ import {
import { validateFlowAPIKey } from './validateKey'
import logger from './logger'
import { utilAddChatMessage } from './addChatMesage'
import { checkPredictions, checkStorage, updatePredictionsUsage, updateStorageUsage } from './quotaUsage'
import {
checkPredictions,
checkPredictionsWithCredits,
checkStorage,
updatePredictionsUsage,
updatePredictionsUsageWithCredits,
updateStorageUsage
} from './quotaUsage'
import { buildAgentGraph } from './buildAgentGraph'
import { getErrorMessage } from '../errors/utils'
import { FLOWISE_METRIC_COUNTERS, FLOWISE_COUNTER_STATUS, IMetricsProvider } from '../Interface.Metrics'
@ -68,7 +75,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
@ -951,7 +958,12 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals
const orgId = org.id
const subscriptionId = org.subscriptionId as string
await checkPredictions(orgId, subscriptionId, appServer.usageCacheManager)
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 predictionCheck = await checkPredictionsWithCredits(orgId, subscriptionId, appServer.usageCacheManager)
const executeData: IExecuteFlowParams = {
incomingInput, // Use the defensively created incomingInput variable
@ -985,7 +997,13 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals
if (!result) {
throw new Error('Job execution failed')
}
await updatePredictionsUsage(orgId, subscriptionId, workspaceId, appServer.usageCacheManager)
// await updatePredictionsUsage(orgId, subscriptionId, workspaceId, appServer.usageCacheManager)
await updatePredictionsUsageWithCredits(
orgId,
subscriptionId,
predictionCheck?.useCredits || false,
appServer.usageCacheManager
)
incrementSuccessMetricCounter(appServer.metricsProvider, isInternal, isAgentFlow)
return result
} else {
@ -997,7 +1015,13 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals
const result = await executeFlow(executeData)
appServer.abortControllerPool.remove(abortControllerId)
await updatePredictionsUsage(orgId, subscriptionId, workspaceId, appServer.usageCacheManager)
// await updatePredictionsUsage(orgId, subscriptionId, workspaceId, appServer.usageCacheManager)
await updatePredictionsUsageWithCredits(
orgId,
subscriptionId,
predictionCheck?.useCredits || false,
appServer.usageCacheManager
)
incrementSuccessMetricCounter(appServer.metricsProvider, isInternal, isAgentFlow)
return result
}

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,58 @@
export const CREDIT_CONSTANTS = {
// PRD: $1 = 100 predictions at $0.01 each, so 1 credit = $0.01
CREDITS_PER_DOLLAR: 100, // 1 credit = $0.01
CENTS_PER_PREDICTION: 1, // $0.01 per prediction in cents
PACKAGES: {
SMALL: {
credits: 1000,
price: 1000 // $10.00 in cents (1000 credits * $0.01)
},
MEDIUM: {
credits: 2500,
price: 2500 // $25.00 in cents (2500 credits * $0.01)
},
LARGE: {
credits: 5000,
price: 5000 // $50.00 in cents (5000 credits * $0.01)
}
},
// Environment variables
ENV_VARS: {
CREDIT_PRODUCT_ID: 'CREDIT_PRODUCT_ID',
BILLING_METER_ID: 'BILLING_METER_ID',
METERED_PRICE_ID: 'METERED_PRICE_ID',
METER_EVENT_NAME: 'METER_EVENT_NAME'
}
} as const
export type CreditPackageType = keyof typeof CREDIT_CONSTANTS.PACKAGES
export type CreditPackage = (typeof CREDIT_CONSTANTS.PACKAGES)[CreditPackageType]
// Helper functions for credit calculations
export const convertCreditsToCents = (credits: number): number => {
return credits * CREDIT_CONSTANTS.CENTS_PER_PREDICTION
}
export const convertCentsToCredits = (cents: number): number => {
return Math.floor(cents / CREDIT_CONSTANTS.CENTS_PER_PREDICTION)
}
export const convertDollarsToCredits = (dollars: number): number => {
return dollars * CREDIT_CONSTANTS.CREDITS_PER_DOLLAR
}
export const convertCreditsToDollars = (credits: number): number => {
return credits / CREDIT_CONSTANTS.CREDITS_PER_DOLLAR
}
// Validation helper
export const isValidPackageType = (packageType: string): packageType is CreditPackageType => {
return packageType in CREDIT_CONSTANTS.PACKAGES
}
// Get package info
export const getPackageInfo = (packageType: CreditPackageType): CreditPackage => {
return CREDIT_CONSTANTS.PACKAGES[packageType]
}

View File

@ -1,6 +1,7 @@
import { StatusCodes } from 'http-status-codes'
import { InternalFlowiseError } from '../errors/internalFlowiseError'
import { UsageCacheManager } from '../UsageCacheManager'
import { StripeManager } from '../StripeManager'
import { LICENSE_QUOTAS } from './constants'
import logger from './logger'
@ -74,6 +75,59 @@ export const checkUsageLimit = async (
}
}
export const checkPredictions = async (orgId: string, subscriptionId: string, usageCacheManager: UsageCacheManager) => {
if (!usageCacheManager || !subscriptionId) return
const currentPredictions: number = (await usageCacheManager.get(`predictions:${orgId}`)) || 0
const quotas = await usageCacheManager.getQuotas(subscriptionId)
const predictionsLimit = quotas[LICENSE_QUOTAS.PREDICTIONS_LIMIT]
if (predictionsLimit === -1) return
if (currentPredictions >= predictionsLimit) {
throw new InternalFlowiseError(StatusCodes.TOO_MANY_REQUESTS, 'Predictions limit exceeded')
}
return {
usage: currentPredictions,
limit: predictionsLimit
}
}
// Enhanced prediction checking that includes credit eligibility
export const checkPredictionsWithCredits = async (orgId: string, subscriptionId: string, usageCacheManager: UsageCacheManager) => {
if (!usageCacheManager || !subscriptionId) return
const eligibility = await checkPredictionEligibility(orgId, subscriptionId, usageCacheManager)
if (!eligibility.allowed) {
throw new InternalFlowiseError(StatusCodes.PAYMENT_REQUIRED, 'Predictions limit exceeded. Please purchase credits to continue.')
}
return {
useCredits: eligibility.useCredits,
remainingCredits: eligibility.remainingCredits,
usage: eligibility.currentUsage,
limit: eligibility.planLimit
}
}
export const checkPredictionEligibility = async (orgId: string, subscriptionId: string, usageCacheManager: UsageCacheManager) => {
try {
if (!usageCacheManager || !subscriptionId) return { allowed: true, useCredits: false }
const stripeManager = await StripeManager.getInstance()
if (!stripeManager) return { allowed: true, useCredits: false }
const eligibility = await stripeManager.checkPredictionEligibility(orgId, subscriptionId)
logger.info(`eligibility: ${JSON.stringify(eligibility)}`)
return eligibility
} catch (error) {
logger.error(`[checkPredictionEligibility] Error checking prediction eligibility: ${error}`)
throw error
}
}
// As predictions limit renew per month, we set to cache with 1 month TTL
export const updatePredictionsUsage = async (
orgId: string,
@ -125,22 +179,31 @@ export const updatePredictionsUsage = async (
}
}
export const checkPredictions = async (orgId: string, subscriptionId: string, usageCacheManager: UsageCacheManager) => {
if (!usageCacheManager || !subscriptionId) return
export const updatePredictionsUsageWithCredits = async (
orgId: string,
subscriptionId: string,
useCredits: boolean = false,
usageCacheManager?: UsageCacheManager
) => {
if (!usageCacheManager) return
const currentPredictions: number = (await usageCacheManager.get(`predictions:${orgId}`)) || 0
const quotas = await usageCacheManager.getQuotas(subscriptionId)
const predictionsLimit = quotas[LICENSE_QUOTAS.PREDICTIONS_LIMIT]
if (predictionsLimit === -1) return
if (currentPredictions >= predictionsLimit) {
throw new InternalFlowiseError(StatusCodes.TOO_MANY_REQUESTS, 'Predictions limit exceeded')
}
return {
usage: currentPredictions,
limit: predictionsLimit
if (useCredits) {
// Report meter usage for credit billing
try {
const stripeManager = await StripeManager.getInstance()
if (stripeManager) {
const subscriptionDetails = await usageCacheManager.getSubscriptionDetails(subscriptionId)
logger.info(`subscription details: ${JSON.stringify(subscriptionDetails)}`)
if (subscriptionDetails && subscriptionDetails.customer) {
await stripeManager.reportMeterUsage(subscriptionDetails.customer as string)
}
}
} catch (error) {
logger.error(`[updatePredictionsUsageWithCredits] Error reporting meter usage: ${error}`)
}
} else {
// Update regular plan usage
await updatePredictionsUsage(orgId, subscriptionId, '', usageCacheManager)
}
}

View File

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

View File

@ -18,13 +18,18 @@ 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) =>
client.post(`/organization/update-subscription-plan`, { subscriptionId, newPlanId, prorationDate })
const getCurrentUsage = () => client.get(`/organization/get-current-usage`)
const getPredictionEligibility = () => client.get(`/organization/prediction-eligibility`)
const purchaseCredits = (packageType) => client.post(`/organization/purchase-credits`, { packageType })
const getCreditsBalance = () => client.get(`/organization/credits-balance`)
const getUsageWithCredits = () => client.get(`/organization/usage-with-credits`)
const getCreditsPackages = () => client.get(`/organization/credits-packages`)
// workspace users
const getAllUsersByWorkspaceId = (workspaceId) => client.get(`/workspaceuser?workspaceId=${workspaceId}`)
@ -55,5 +60,10 @@ export default {
getPlanProration,
updateSubscriptionPlan,
getCurrentUsage,
getPredictionEligibility,
purchaseCredits,
getCreditsBalance,
getUsageWithCredits,
getCreditsPackages,
deleteOrganizationUser
}

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

@ -33,7 +33,7 @@ import SettingsSection from '@/ui-component/form/settings'
import PricingDialog from '@/ui-component/subscription/PricingDialog'
// Icons
import { IconAlertCircle, IconCreditCard, IconExternalLink, IconSparkles, IconX } from '@tabler/icons-react'
import { IconAlertCircle, IconCoins, IconCreditCard, IconExternalLink, IconSparkles, IconX } from '@tabler/icons-react'
// API
import accountApi from '@/api/account.api'
@ -57,6 +57,29 @@ const calculatePercentage = (count, total) => {
return Math.min((count / total) * 100, 100)
}
const calculateTotalCredits = (grants) => {
if (!grants || !Array.isArray(grants)) return 0
return grants.reduce((total, grant) => {
const grantCredits = grant?.credits || 0
return total + grantCredits
}, 0)
}
const calculateTotalUsage = (grants) => {
if (!grants || !Array.isArray(grants)) return 0
return grants.reduce((total, grant) => {
const usage = grant?.usage || 0
return total + usage
}, 0)
}
const calculateAvailableCredits = (grants) => {
if (!grants || !Array.isArray(grants)) return 0
const totalCredits = calculateTotalCredits(grants)
const totalUsage = calculateTotalUsage(grants)
return Math.max(0, totalCredits - totalUsage)
}
const AccountSettings = () => {
const theme = useTheme()
const dispatch = useDispatch()
@ -88,6 +111,23 @@ const AccountSettings = () => {
const [purchasedSeats, setPurchasedSeats] = useState(0)
const [occupiedSeats, setOccupiedSeats] = useState(0)
const [totalSeats, setTotalSeats] = useState(0)
const [creditsBalance, setCreditsBalance] = useState(null)
const totalCredits = useMemo(() => {
return creditsBalance ? creditsBalance.totalCredits || 0 : 0
}, [creditsBalance])
const totalUsage = useMemo(() => {
return creditsBalance ? creditsBalance.totalUsage || 0 : 0
}, [creditsBalance])
const availableCredits = useMemo(() => {
return creditsBalance ? creditsBalance.availableCredits || 0 : 0
}, [creditsBalance])
const [creditsPackages, setCreditsPackages] = useState([])
const [openCreditsDialog, setOpenCreditsDialog] = useState(false)
const [selectedPackage, setSelectedPackage] = useState(null)
const [isPurchasingCredits, setIsPurchasingCredits] = useState(false)
const predictionsUsageInPercent = useMemo(() => {
return usage ? calculatePercentage(usage.predictions?.usage, usage.predictions?.limit) : 0
@ -106,6 +146,11 @@ const AccountSettings = () => {
const getCustomerDefaultSourceApi = useApi(userApi.getCustomerDefaultSource)
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
const getCurrentUsageApi = useApi(userApi.getCurrentUsage)
const getCreditsBalanceApi = useApi(userApi.getCreditsBalance)
const getCreditsPackagesApi = useApi(userApi.getCreditsPackages)
const getUsageWithCreditsApi = useApi(userApi.getUsageWithCredits)
const purchaseCreditsApi = useApi(userApi.purchaseCredits)
const getPredictionEligibilityApi = useApi(userApi.getPredictionEligibility)
useEffect(() => {
if (isCloud) {
@ -113,6 +158,10 @@ const AccountSettings = () => {
getPricingPlansApi.request()
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
getCurrentUsageApi.request()
getCreditsBalanceApi.request()
getCreditsPackagesApi.request()
getUsageWithCreditsApi.request()
getPredictionEligibilityApi.request()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloud])
@ -140,13 +189,25 @@ const AccountSettings = () => {
}, [getCurrentUsageApi.data])
useEffect(() => {
if (openRemoveSeatsDialog || openAddSeatsDialog) {
if (getCreditsBalanceApi.data) {
setCreditsBalance(getCreditsBalanceApi.data)
}
}, [getCreditsBalanceApi.data])
useEffect(() => {
if (getCreditsPackagesApi.data) {
setCreditsPackages(getCreditsPackagesApi.data)
}
}, [getCreditsPackagesApi.data])
useEffect(() => {
if (openRemoveSeatsDialog || openAddSeatsDialog || openCreditsDialog) {
setSeatsQuantity(0)
getCustomerDefaultSourceApi.request(currentUser?.activeOrganizationCustomerId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openRemoveSeatsDialog, openAddSeatsDialog])
}, [openRemoveSeatsDialog, openAddSeatsDialog, openCreditsDialog])
useEffect(() => {
if (getAdditionalSeatsProrationApi.data) {
@ -347,7 +408,7 @@ const AccountSettings = () => {
}
}
const handleSeatsModification = async (newSeatsAmount) => {
const handleSeatsModification = async (newSeatsAmount, increase) => {
try {
setIsUpdatingSeats(true)
@ -355,23 +416,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) {
@ -425,6 +508,63 @@ const AccountSettings = () => {
}
}
const handlePurchaseCredits = async (packageType) => {
try {
setIsPurchasingCredits(true)
const response = await purchaseCreditsApi.request(packageType)
// Check for success - either explicit success flag or 200 status code
if (response.data?.success || response.status === 200) {
enqueueSnackbar({
message: 'Credits purchased successfully!',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
autoHideDuration: 10000,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
// Refresh credits data
getCreditsBalanceApi.request()
getUsageWithCreditsApi.request()
setOpenCreditsDialog(false)
setSelectedPackage(null)
}
} catch (error) {
console.error('Error purchasing credits:', error)
enqueueSnackbar({
message: `Failed to purchase credits: ${
typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data || error.message
}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
} finally {
setIsPurchasingCredits(false)
}
}
const handleCreditsDialogClose = () => {
if (!isPurchasingCredits) {
setOpenCreditsDialog(false)
setSelectedPackage(null)
}
}
// Calculate empty seats
const emptySeats = Math.min(purchasedSeats, totalSeats - occupiedSeats)
@ -704,6 +844,74 @@ const AccountSettings = () => {
</Box>
</Box>
</SettingsSection>
<SettingsSection title='Credits'>
<Box
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)'
}}
>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
justifyContent: 'center',
gap: 1,
px: 2.5,
py: 2
}}
>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Available Credits:</Typography>
<Typography sx={{ ml: 1, color: theme.palette.success.dark }} variant='h3'>
{getCreditsBalanceApi.loading ? <CircularProgress size={16} /> : availableCredits || 0}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Credits Used:</Typography>
<Typography sx={{ ml: 1, color: 'inherit' }} variant='h3'>
{getCreditsBalanceApi.loading ? <CircularProgress size={16} /> : totalUsage || 0}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Total Credits Purchased:</Typography>
<Typography sx={{ ml: 1, color: 'inherit' }} variant='h3'>
{getCreditsBalanceApi.loading ? <CircularProgress size={16} /> : totalCredits || 0}
</Typography>
</Stack>
<Typography
sx={{ opacity: customization.isDarkMode ? 0.7 : 1 }}
variant='body2'
color='text.secondary'
>
Purchase credits for predictions beyond your plan limits
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
px: 2.5,
py: 2,
gap: 2
}}
>
<StyledButton
variant='contained'
disabled={!currentUser.isOrganizationAdmin}
onClick={() => setOpenCreditsDialog(true)}
startIcon={<IconCoins />}
sx={{ borderRadius: 2, height: 40 }}
>
Buy Credits
</StyledButton>
</Box>
</Box>
</SettingsSection>
<SettingsSection
action={
<StyledButton onClick={saveProfileData} sx={{ borderRadius: 2, height: 40 }} variant='contained'>
@ -1160,7 +1368,7 @@ const AccountSettings = () => {
</Button>
<Button
variant='outlined'
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity)}
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity, false)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||
@ -1414,7 +1622,7 @@ const AccountSettings = () => {
</Button>
<Button
variant='contained'
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats)}
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats, true)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||
@ -1435,6 +1643,167 @@ const AccountSettings = () => {
</DialogActions>
)}
</Dialog>
{/* Credits Purchase Dialog */}
<Dialog fullWidth maxWidth='sm' open={openCreditsDialog} onClose={handleCreditsDialogClose}>
<DialogTitle variant='h4'>Purchase Credits</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<Typography variant='body2' color='text.secondary'>
Credits are used for predictions beyond your plan limits. Each prediction costs 1 credit.
</Typography>
{/* Current Credits Balance */}
<Box sx={{ p: 2, backgroundColor: theme.palette.background.default, borderRadius: 1 }}>
<Typography variant='subtitle2' gutterBottom>
Current Balance
</Typography>
<Typography variant='h4' color='success.main'>
{availableCredits || 0} Available Credits
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ mt: 1 }}>
Total: {totalCredits || 0} | Used: {totalUsage || 0}
</Typography>
</Box>
{/* Payment Method Check */}
{getCustomerDefaultSourceApi.loading ? (
<CircularProgress size={20} />
) : getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 2 }}>
<Typography variant='subtitle2'>Payment Method</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card && (
<>
<IconCreditCard size={20} stroke={1.5} color={theme.palette.primary.main} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ textTransform: 'capitalize' }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.brand}
</Typography>
<Typography>
{' '}
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.last4}
</Typography>
<Typography color='text.secondary'>
(expires{' '}
{
getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card
.exp_month
}
/
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.exp_year}
)
</Typography>
</Box>
</>
)}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
<Typography color='error' sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconAlertCircle size={20} />
No payment method found
</Typography>
<Button
variant='contained'
endIcon={<IconExternalLink />}
onClick={() => {
setOpenCreditsDialog(false)
handleBillingPortalClick()
}}
>
Add Payment Method in Billing Portal
</Button>
</Box>
)}
{/* Credit Packages */}
{getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant='subtitle2'>Select Credit Package</Typography>
{getCreditsPackagesApi.loading ? (
<CircularProgress size={20} />
) : (
creditsPackages.map((pkg) => (
<Box
key={pkg.credits}
sx={{
p: 2,
border: 2,
borderColor: selectedPackage?.credits === pkg.credits ? 'primary.main' : 'divider',
borderRadius: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.light',
backgroundColor: 'action.hover'
}
}}
onClick={() => setSelectedPackage(pkg)}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant='h6'>
{pkg.displayName || `${pkg.credits.toLocaleString()} Credits`}
</Typography>
<Typography variant='body2' color='text.secondary'>
${(pkg.price / 100).toFixed(2)} USD
</Typography>
{pkg.hasDiscount && (
<Typography variant='caption' color='success.main' sx={{ fontWeight: 'bold' }}>
Save ${(pkg.credits * 0.01 - pkg.price / 100).toFixed(2)}
</Typography>
)}
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant='body2' color='text.secondary'>
${(pkg.price / 100 / pkg.credits).toFixed(3)} per credit
</Typography>
{pkg.hasDiscount && (
<Typography variant='caption' color='success.main'>
{pkg.discountPercent}% off
</Typography>
)}
</Box>
</Box>
</Box>
))
)}
</Box>
)}
</Box>
</DialogContent>
{getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method && (
<DialogActions>
<Button onClick={handleCreditsDialogClose} disabled={isPurchasingCredits}>
Cancel
</Button>
<Button
variant='contained'
onClick={() => handlePurchaseCredits(selectedPackage?.id)}
disabled={
!selectedPackage ||
isPurchasingCredits ||
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data
}
startIcon={<IconCoins />}
>
{isPurchasingCredits ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color='inherit' />
Processing...
</Box>
) : (
`Purchase ${(selectedPackage?.credits || 0).toLocaleString()} Credits for $${(
(selectedPackage?.price || 0) / 100
).toFixed(2)}`
)}
</Button>
</DialogActions>
)}
</Dialog>
</MainCard>
)
}

File diff suppressed because one or more lines are too long