Compare commits
51 Commits
main
...
feature/su
| Author | SHA1 | Date |
|---|---|---|
|
|
20441422b3 | |
|
|
794ddbc71b | |
|
|
ddfecf725c | |
|
|
e612716c7c | |
|
|
66167761e0 | |
|
|
bb77d7e792 | |
|
|
dbf5a2edc5 | |
|
|
051d8ef401 | |
|
|
871cf86925 | |
|
|
79195ee8b1 | |
|
|
790a762ddb | |
|
|
370c55aa78 | |
|
|
99f7f7dc6d | |
|
|
784b98a818 | |
|
|
268763ccee | |
|
|
7150c5434d | |
|
|
77f738ebb4 | |
|
|
9ae54bc921 | |
|
|
ba71c2975e | |
|
|
46dc4324b6 | |
|
|
da8623d8aa | |
|
|
deae7d9aff | |
|
|
c74e1750f3 | |
|
|
56c51ac7b5 | |
|
|
cc6931ecfe | |
|
|
18d2e0f7e6 | |
|
|
5e25ce5dd4 | |
|
|
611b312672 | |
|
|
fda7ca5523 | |
|
|
3b1c79f053 | |
|
|
fb64ea7918 | |
|
|
6ace6617fa | |
|
|
324868a021 | |
|
|
6d39b83c51 | |
|
|
764cc6c144 | |
|
|
36c872f2de | |
|
|
9a41effa93 | |
|
|
2f87f649cd | |
|
|
d1f71ce433 | |
|
|
52e2dab190 | |
|
|
81c8d42828 | |
|
|
e596c4aecd | |
|
|
cbcda5538d | |
|
|
4a2ea0a425 | |
|
|
407c8bb1a8 | |
|
|
aaf2f6eb19 | |
|
|
260219e94d | |
|
|
32ade38c06 | |
|
|
7d3c070714 | |
|
|
71cbe601ee | |
|
|
4d52643621 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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\` ;`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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\` ;`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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";`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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";`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import client from './client'
|
||||
|
||||
const getOrganizationById = (id) => client.get(`/organization?id=${id}`)
|
||||
|
||||
export default {
|
||||
getOrganizationById
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import client from './client'
|
||||
|
||||
const getWorkspaceByUserId = (userId) => client.get(`/workspaceuser?userId=${userId}`)
|
||||
export default {
|
||||
getWorkspaceByUserId
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
76385
pnpm-lock.yaml
76385
pnpm-lock.yaml
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue