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",
|
"s3-streamlogger": "^1.11.0",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"stripe": "^15.6.0",
|
"stripe": "^18.3.0",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
"typeorm": "^0.3.6",
|
"typeorm": "^0.3.6",
|
||||||
"uuid": "^9.0.1",
|
"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 { ErrorMessage, LoggedInUser } from './enterprise/Interface.Enterprise'
|
||||||
import { Permissions } from './enterprise/rbac/Permissions'
|
import { Permissions } from './enterprise/rbac/Permissions'
|
||||||
import { LoginMethodService } from './enterprise/services/login-method.service'
|
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 { OrganizationService } from './enterprise/services/organization.service'
|
||||||
import Auth0SSO from './enterprise/sso/Auth0SSO'
|
import Auth0SSO from './enterprise/sso/Auth0SSO'
|
||||||
import AzureSSO from './enterprise/sso/AzureSSO'
|
import AzureSSO from './enterprise/sso/AzureSSO'
|
||||||
|
|
@ -320,13 +321,18 @@ export class IdentityManager {
|
||||||
return await this.stripeManager.getAdditionalSeatsProration(subscriptionId, newQuantity)
|
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 (!subscriptionId) return {}
|
||||||
|
|
||||||
if (!this.stripeManager) {
|
if (!this.stripeManager) {
|
||||||
throw new Error('Stripe manager is not initialized')
|
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
|
// Fetch product details to get quotas
|
||||||
const items = subscription.items.data
|
const items = subscription.items.data
|
||||||
|
|
@ -358,7 +364,13 @@ export class IdentityManager {
|
||||||
subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription)
|
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) {
|
public async getPlanProration(subscriptionId: string, newPlanId: string) {
|
||||||
|
|
@ -370,6 +382,45 @@ export class IdentityManager {
|
||||||
return await this.stripeManager.getPlanProration(subscriptionId, newPlanId)
|
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) {
|
public async updateSubscriptionPlan(req: Request, subscriptionId: string, newPlanId: string, prorationDate: number) {
|
||||||
if (!subscriptionId || !newPlanId) return {}
|
if (!subscriptionId || !newPlanId) return {}
|
||||||
|
|
||||||
|
|
@ -379,85 +430,138 @@ export class IdentityManager {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, GeneralErrorMessage.UNAUTHORIZED)
|
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, GeneralErrorMessage.UNAUTHORIZED)
|
||||||
}
|
}
|
||||||
const { success, subscription } = await this.stripeManager.updateSubscriptionPlan(subscriptionId, newPlanId, prorationDate)
|
try {
|
||||||
if (success) {
|
const result = await this.stripeManager.updateSubscriptionPlan(subscriptionId, newPlanId, prorationDate)
|
||||||
// Fetch product details to get quotas
|
const { success, subscription, special_case, paymentFailed, paymentError } = result
|
||||||
const product = await this.stripeManager.getStripe().products.retrieve(newPlanId)
|
if (success) {
|
||||||
const productMetadata = product.metadata
|
// 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
|
try {
|
||||||
const quotas: Record<string, number> = {}
|
const organizationService = new OrganizationService()
|
||||||
for (const key in productMetadata) {
|
|
||||||
if (key.startsWith('quota:')) {
|
// Find organization by subscriptionId
|
||||||
quotas[key] = parseInt(productMetadata[key])
|
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 {
|
return {
|
||||||
status: 'success',
|
status: 'error',
|
||||||
user: loggedInUser
|
message: 'Payment or subscription update not completed'
|
||||||
}
|
}
|
||||||
}
|
} catch (error: any) {
|
||||||
return {
|
// Enhanced error handling for payment method failures
|
||||||
status: 'error',
|
if (error.type === 'StripeCardError' || error.code === 'card_declined') {
|
||||||
message: 'Payment or subscription update not completed'
|
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 { LICENSE_QUOTAS } from './utils/constants'
|
||||||
import { StripeManager } from './StripeManager'
|
import { StripeManager } from './StripeManager'
|
||||||
|
|
||||||
const DISABLED_QUOTAS = {
|
const getDisabledQuotas = () => ({
|
||||||
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: 0,
|
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: 0,
|
||||||
[LICENSE_QUOTAS.STORAGE_LIMIT]: 0, // in MB
|
[LICENSE_QUOTAS.STORAGE_LIMIT]: 0, // in MB
|
||||||
[LICENSE_QUOTAS.FLOWS_LIMIT]: 0,
|
[LICENSE_QUOTAS.FLOWS_LIMIT]: 0,
|
||||||
[LICENSE_QUOTAS.USERS_LIMIT]: 0,
|
[LICENSE_QUOTAS.USERS_LIMIT]: 0,
|
||||||
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: 0
|
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: 0
|
||||||
}
|
})
|
||||||
|
|
||||||
const UNLIMITED_QUOTAS = {
|
const getUnlimitedQuotas = () => ({
|
||||||
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: -1,
|
[LICENSE_QUOTAS.PREDICTIONS_LIMIT]: -1,
|
||||||
[LICENSE_QUOTAS.STORAGE_LIMIT]: -1,
|
[LICENSE_QUOTAS.STORAGE_LIMIT]: -1,
|
||||||
[LICENSE_QUOTAS.FLOWS_LIMIT]: -1,
|
[LICENSE_QUOTAS.FLOWS_LIMIT]: -1,
|
||||||
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
|
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
|
||||||
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: -1
|
[LICENSE_QUOTAS.ADDITIONAL_SEATS_LIMIT]: -1
|
||||||
}
|
})
|
||||||
|
|
||||||
export class UsageCacheManager {
|
export class UsageCacheManager {
|
||||||
private cache: Cache
|
private cache: Cache
|
||||||
|
|
@ -67,7 +67,7 @@ export class UsageCacheManager {
|
||||||
public async getSubscriptionDetails(subscriptionId: string, withoutCache: boolean = false): Promise<Record<string, any>> {
|
public async getSubscriptionDetails(subscriptionId: string, withoutCache: boolean = false): Promise<Record<string, any>> {
|
||||||
const stripeManager = await StripeManager.getInstance()
|
const stripeManager = await StripeManager.getInstance()
|
||||||
if (!stripeManager || !subscriptionId) {
|
if (!stripeManager || !subscriptionId) {
|
||||||
return UNLIMITED_QUOTAS
|
return getUnlimitedQuotas()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip cache if withoutCache is true
|
// 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>> {
|
public async getQuotas(subscriptionId: string, withoutCache: boolean = false): Promise<Record<string, number>> {
|
||||||
const stripeManager = await StripeManager.getInstance()
|
const stripeManager = await StripeManager.getInstance()
|
||||||
if (!stripeManager || !subscriptionId) {
|
if (!stripeManager || !subscriptionId) {
|
||||||
return UNLIMITED_QUOTAS
|
return getUnlimitedQuotas()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip cache if withoutCache is true
|
// Skip cache if withoutCache is true
|
||||||
|
|
@ -105,7 +105,7 @@ export class UsageCacheManager {
|
||||||
const subscription = await stripeManager.getStripe().subscriptions.retrieve(subscriptionId)
|
const subscription = await stripeManager.getStripe().subscriptions.retrieve(subscriptionId)
|
||||||
const items = subscription.items.data
|
const items = subscription.items.data
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return DISABLED_QUOTAS
|
return getDisabledQuotas()
|
||||||
}
|
}
|
||||||
|
|
||||||
const productId = items[0].price.product as string
|
const productId = items[0].price.product as string
|
||||||
|
|
@ -113,7 +113,7 @@ export class UsageCacheManager {
|
||||||
const productMetadata = product.metadata
|
const productMetadata = product.metadata
|
||||||
|
|
||||||
if (!productMetadata || Object.keys(productMetadata).length === 0) {
|
if (!productMetadata || Object.keys(productMetadata).length === 0) {
|
||||||
return DISABLED_QUOTAS
|
return getDisabledQuotas()
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotas: Record<string, number> = {}
|
const quotas: Record<string, number> = {}
|
||||||
|
|
@ -162,6 +162,51 @@ export class UsageCacheManager {
|
||||||
this.set(cacheKey, updatedData, 3600000) // Cache for 1 hour
|
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> {
|
public async get<T>(key: string): Promise<T | null> {
|
||||||
if (!this.cache) await this.initialize()
|
if (!this.cache) await this.initialize()
|
||||||
const value = await this.cache.get<T>(key)
|
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 { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mariadb/1734074497540-AddPersonalWorkspace'
|
||||||
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mariadb/1737076223692-RefactorEnterpriseDatabase'
|
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mariadb/1737076223692-RefactorEnterpriseDatabase'
|
||||||
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mariadb/1746862866554-ExecutionLinkWorkspaceId'
|
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mariadb/1746862866554-ExecutionLinkWorkspaceId'
|
||||||
|
import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/mariadb/1749714174104-AddStatusInOrganization'
|
||||||
|
|
||||||
export const mariadbMigrations = [
|
export const mariadbMigrations = [
|
||||||
Init1693840429259,
|
Init1693840429259,
|
||||||
|
|
@ -98,5 +99,6 @@ export const mariadbMigrations = [
|
||||||
FixOpenSourceAssistantTable1743758056188,
|
FixOpenSourceAssistantTable1743758056188,
|
||||||
AddErrorToEvaluationRun1744964560174,
|
AddErrorToEvaluationRun1744964560174,
|
||||||
ExecutionLinkWorkspaceId1746862866554,
|
ExecutionLinkWorkspaceId1746862866554,
|
||||||
ModifyExecutionDataColumnType1747902489801
|
ModifyExecutionDataColumnType1747902489801,
|
||||||
|
AddStatusInOrganization1749714174104
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati
|
||||||
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mysql/1734074497540-AddPersonalWorkspace'
|
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/mysql/1734074497540-AddPersonalWorkspace'
|
||||||
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mysql/1737076223692-RefactorEnterpriseDatabase'
|
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/mysql/1737076223692-RefactorEnterpriseDatabase'
|
||||||
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mysql/1746862866554-ExecutionLinkWorkspaceId'
|
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/mysql/1746862866554-ExecutionLinkWorkspaceId'
|
||||||
|
import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/mysql/1749714174104-AddStatusInOrganization'
|
||||||
|
|
||||||
export const mysqlMigrations = [
|
export const mysqlMigrations = [
|
||||||
Init1693840429259,
|
Init1693840429259,
|
||||||
|
|
@ -100,5 +101,6 @@ export const mysqlMigrations = [
|
||||||
AddErrorToEvaluationRun1744964560174,
|
AddErrorToEvaluationRun1744964560174,
|
||||||
FixErrorsColumnInEvaluationRun1746437114935,
|
FixErrorsColumnInEvaluationRun1746437114935,
|
||||||
ExecutionLinkWorkspaceId1746862866554,
|
ExecutionLinkWorkspaceId1746862866554,
|
||||||
ModifyExecutionDataColumnType1747902489801
|
ModifyExecutionDataColumnType1747902489801,
|
||||||
|
AddStatusInOrganization1749714174104
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati
|
||||||
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/postgres/1734074497540-AddPersonalWorkspace'
|
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/postgres/1734074497540-AddPersonalWorkspace'
|
||||||
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/postgres/1737076223692-RefactorEnterpriseDatabase'
|
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/postgres/1737076223692-RefactorEnterpriseDatabase'
|
||||||
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/postgres/1746862866554-ExecutionLinkWorkspaceId'
|
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/postgres/1746862866554-ExecutionLinkWorkspaceId'
|
||||||
|
import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/postgres/1749714174104-AddStatusInOrganization'
|
||||||
|
|
||||||
export const postgresMigrations = [
|
export const postgresMigrations = [
|
||||||
Init1693891895163,
|
Init1693891895163,
|
||||||
|
|
@ -98,5 +99,6 @@ export const postgresMigrations = [
|
||||||
FixOpenSourceAssistantTable1743758056188,
|
FixOpenSourceAssistantTable1743758056188,
|
||||||
AddErrorToEvaluationRun1744964560174,
|
AddErrorToEvaluationRun1744964560174,
|
||||||
ExecutionLinkWorkspaceId1746862866554,
|
ExecutionLinkWorkspaceId1746862866554,
|
||||||
ModifyExecutionSessionIdFieldType1748450230238
|
ModifyExecutionSessionIdFieldType1748450230238,
|
||||||
|
AddStatusInOrganization1749714174104
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import { AddSSOColumns1730519457880 } from '../../../enterprise/database/migrati
|
||||||
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/sqlite/1734074497540-AddPersonalWorkspace'
|
import { AddPersonalWorkspace1734074497540 } from '../../../enterprise/database/migrations/sqlite/1734074497540-AddPersonalWorkspace'
|
||||||
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/sqlite/1737076223692-RefactorEnterpriseDatabase'
|
import { RefactorEnterpriseDatabase1737076223692 } from '../../../enterprise/database/migrations/sqlite/1737076223692-RefactorEnterpriseDatabase'
|
||||||
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/sqlite/1746862866554-ExecutionLinkWorkspaceId'
|
import { ExecutionLinkWorkspaceId1746862866554 } from '../../../enterprise/database/migrations/sqlite/1746862866554-ExecutionLinkWorkspaceId'
|
||||||
|
import { AddStatusInOrganization1749714174104 } from '../../../enterprise/database/migrations/sqlite/1749714174104-AddStatusInOrganization'
|
||||||
|
|
||||||
export const sqliteMigrations = [
|
export const sqliteMigrations = [
|
||||||
Init1693835579790,
|
Init1693835579790,
|
||||||
|
|
@ -94,5 +95,6 @@ export const sqliteMigrations = [
|
||||||
AddExecutionEntity1738090872625,
|
AddExecutionEntity1738090872625,
|
||||||
FixOpenSourceAssistantTable1743758056188,
|
FixOpenSourceAssistantTable1743758056188,
|
||||||
AddErrorToEvaluationRun1744964560174,
|
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 { StatusCodes } from 'http-status-codes'
|
||||||
import { OrganizationErrorMessage, OrganizationService } from '../services/organization.service'
|
import { QueryRunner } from 'typeorm'
|
||||||
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
|
||||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||||
import { Organization } from '../database/entities/organization.entity'
|
|
||||||
import { GeneralErrorMessage } from '../../utils/constants'
|
import { GeneralErrorMessage } from '../../utils/constants'
|
||||||
import { OrganizationUserService } from '../services/organization-user.service'
|
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
||||||
import { getCurrentUsage } from '../../utils/quotaUsage'
|
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 {
|
export class OrganizationController {
|
||||||
public async create(req: Request, res: Response, next: NextFunction) {
|
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) {
|
public async update(req: Request, res: Response, next: NextFunction) {
|
||||||
|
let queryRunner: QueryRunner | undefined
|
||||||
try {
|
try {
|
||||||
|
queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner()
|
||||||
|
await queryRunner.connect()
|
||||||
const organizationService = new OrganizationService()
|
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)
|
return res.status(StatusCodes.OK).json(organization)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction()
|
||||||
next(error)
|
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) {
|
public async updateAdditionalSeats(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { subscriptionId, quantity, prorationDate } = req.body
|
const { subscriptionId, quantity, prorationDate, increase } = req.body
|
||||||
if (!subscriptionId) {
|
if (!subscriptionId) {
|
||||||
return res.status(400).json({ error: 'Subscription ID is required' })
|
return res.status(400).json({ error: 'Subscription ID is required' })
|
||||||
}
|
}
|
||||||
|
|
@ -137,8 +144,10 @@ export class OrganizationController {
|
||||||
if (!prorationDate) {
|
if (!prorationDate) {
|
||||||
return res.status(400).json({ error: 'Proration date is required' })
|
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 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)
|
return res.status(StatusCodes.OK).json(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -184,4 +193,82 @@ export class OrganizationController {
|
||||||
next(error)
|
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'
|
DEFAULT_ORGANIZATION = 'Default Organization'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum OrganizationStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
UNDER_REVIEW = 'under_review',
|
||||||
|
PAST_DUE = 'past_due'
|
||||||
|
}
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Organization {
|
export class Organization {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
|
@ -19,6 +25,9 @@ export class Organization {
|
||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
subscriptionId?: string
|
subscriptionId?: string
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: OrganizationStatus.ACTIVE })
|
||||||
|
status: string
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdDate?: Date
|
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('/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
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||||
import { generateId } from '../../utils'
|
import { generateId } from '../../utils'
|
||||||
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
||||||
import { Telemetry } from '../../utils/telemetry'
|
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 { isInvalidName, isInvalidUUID } from '../utils/validation.util'
|
||||||
import { UserErrorMessage, UserService } from './user.service'
|
import { UserErrorMessage, UserService } from './user.service'
|
||||||
|
|
||||||
export const enum OrganizationErrorMessage {
|
export const enum OrganizationErrorMessage {
|
||||||
INVALID_ORGANIZATION_ID = 'Invalid Organization Id',
|
INVALID_ORGANIZATION_ID = 'Invalid Organization Id',
|
||||||
|
INVALID_ORGANIZATION_STATUS = 'Invalid Organization Status',
|
||||||
INVALID_ORGANIZATION_NAME = 'Invalid Organization Name',
|
INVALID_ORGANIZATION_NAME = 'Invalid Organization Name',
|
||||||
ORGANIZATION_NOT_FOUND = 'Organization Not Found',
|
ORGANIZATION_NOT_FOUND = 'Organization Not Found',
|
||||||
ORGANIZATION_FOUND_MULTIPLE = 'Organization Found Multiple',
|
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)
|
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) {
|
public async readOrganizationById(id: string | undefined, queryRunner: QueryRunner) {
|
||||||
this.validateOrganizationId(id)
|
this.validateOrganizationId(id)
|
||||||
return await queryRunner.manager.findOneBy(Organization, { id })
|
return await queryRunner.manager.findOneBy(Organization, { id })
|
||||||
|
|
@ -49,6 +56,10 @@ export class OrganizationService {
|
||||||
return await queryRunner.manager.findOneBy(Organization, { name })
|
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) {
|
public async countOrganizations(queryRunner: QueryRunner) {
|
||||||
return await queryRunner.manager.count(Organization)
|
return await queryRunner.manager.count(Organization)
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +70,8 @@ export class OrganizationService {
|
||||||
|
|
||||||
public createNewOrganization(data: Partial<Organization>, queryRunner: QueryRunner, isRegister: boolean = false) {
|
public createNewOrganization(data: Partial<Organization>, queryRunner: QueryRunner, isRegister: boolean = false) {
|
||||||
this.validateOrganizationName(data.name, isRegister)
|
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.updatedBy = data.createdBy
|
||||||
data.id = generateId()
|
data.id = generateId()
|
||||||
|
|
||||||
|
|
@ -91,30 +104,20 @@ export class OrganizationService {
|
||||||
return newOrganization
|
return newOrganization
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrganization(newOrganizationData: Partial<Organization>) {
|
public async updateOrganization(newOrganizationData: Partial<Organization>, queryRunner: QueryRunner, fromStripe: boolean = false) {
|
||||||
const queryRunner = this.dataSource.createQueryRunner()
|
|
||||||
await queryRunner.connect()
|
|
||||||
|
|
||||||
const oldOrganizationData = await this.readOrganizationById(newOrganizationData.id, queryRunner)
|
const oldOrganizationData = await this.readOrganizationById(newOrganizationData.id, queryRunner)
|
||||||
if (!oldOrganizationData) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, OrganizationErrorMessage.ORGANIZATION_NOT_FOUND)
|
if (!oldOrganizationData) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, OrganizationErrorMessage.ORGANIZATION_NOT_FOUND)
|
||||||
const user = await this.userService.readUserById(newOrganizationData.updatedBy, queryRunner)
|
const user = await this.userService.readUserById(newOrganizationData.updatedBy, queryRunner)
|
||||||
if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND)
|
if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND)
|
||||||
if (newOrganizationData.name) {
|
if (newOrganizationData.name) this.validateOrganizationName(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
|
newOrganizationData.createdBy = oldOrganizationData.createdBy
|
||||||
|
|
||||||
let updateOrganization = queryRunner.manager.merge(Organization, oldOrganizationData, newOrganizationData)
|
let updateOrganization = queryRunner.manager.merge(Organization, oldOrganizationData, newOrganizationData)
|
||||||
try {
|
await this.saveOrganization(updateOrganization, queryRunner)
|
||||||
await queryRunner.startTransaction()
|
|
||||||
await this.saveOrganization(updateOrganization, queryRunner)
|
|
||||||
await queryRunner.commitTransaction()
|
|
||||||
} catch (error) {
|
|
||||||
await queryRunner.rollbackTransaction()
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
await queryRunner.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateOrganization
|
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 'global-agent/bootstrap'
|
||||||
import { UsageCacheManager } from './UsageCacheManager'
|
import { UsageCacheManager } from './UsageCacheManager'
|
||||||
import { Workspace } from './enterprise/database/entities/workspace.entity'
|
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 { GeneralRole, Role } from './enterprise/database/entities/role.entity'
|
||||||
import { migrateApiKeysFromJsonToDb } from './utils/apiKey'
|
import { migrateApiKeysFromJsonToDb } from './utils/apiKey'
|
||||||
import { ExpressAdapter } from '@bull-board/express'
|
import { ExpressAdapter } from '@bull-board/express'
|
||||||
|
import { StripeWebhooks } from './enterprise/webhooks/stripe'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
|
|
@ -157,6 +158,10 @@ export class App {
|
||||||
}
|
}
|
||||||
|
|
||||||
async config() {
|
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
|
// Limit is needed to allow sending/receiving base64 encoded string
|
||||||
const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb'
|
const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb'
|
||||||
this.app.use(express.json({ limit: flowise_file_size_limit }))
|
this.app.use(express.json({ limit: flowise_file_size_limit }))
|
||||||
|
|
@ -251,6 +256,10 @@ export class App {
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return res.status(401).json({ error: 'Unauthorized Access' })
|
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 subscriptionId = org.subscriptionId as string
|
||||||
const customerId = org.customerId as string
|
const customerId = org.customerId as string
|
||||||
const features = await this.identityManager.getFeaturesByPlan(subscriptionId)
|
const features = await this.identityManager.getFeaturesByPlan(subscriptionId)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,14 @@ import {
|
||||||
import { validateFlowAPIKey } from './validateKey'
|
import { validateFlowAPIKey } from './validateKey'
|
||||||
import logger from './logger'
|
import logger from './logger'
|
||||||
import { utilAddChatMessage } from './addChatMesage'
|
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 { buildAgentGraph } from './buildAgentGraph'
|
||||||
import { getErrorMessage } from '../errors/utils'
|
import { getErrorMessage } from '../errors/utils'
|
||||||
import { FLOWISE_METRIC_COUNTERS, FLOWISE_COUNTER_STATUS, IMetricsProvider } from '../Interface.Metrics'
|
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 { OMIT_QUEUE_JOB_DATA } from './constants'
|
||||||
import { executeAgentFlow } from './buildAgentflow'
|
import { executeAgentFlow } from './buildAgentflow'
|
||||||
import { Workspace } from '../enterprise/database/entities/workspace.entity'
|
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
|
* 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 orgId = org.id
|
||||||
const subscriptionId = org.subscriptionId as string
|
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 = {
|
const executeData: IExecuteFlowParams = {
|
||||||
incomingInput, // Use the defensively created incomingInput variable
|
incomingInput, // Use the defensively created incomingInput variable
|
||||||
|
|
@ -985,7 +997,13 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('Job execution failed')
|
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)
|
incrementSuccessMetricCounter(appServer.metricsProvider, isInternal, isAgentFlow)
|
||||||
return result
|
return result
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -997,7 +1015,13 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals
|
||||||
const result = await executeFlow(executeData)
|
const result = await executeFlow(executeData)
|
||||||
|
|
||||||
appServer.abortControllerPool.remove(abortControllerId)
|
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)
|
incrementSuccessMetricCounter(appServer.metricsProvider, isInternal, isAgentFlow)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export const WHITELIST_URLS = [
|
||||||
'/api/v1/loginmethod',
|
'/api/v1/loginmethod',
|
||||||
'/api/v1/pricing',
|
'/api/v1/pricing',
|
||||||
'/api/v1/user/test',
|
'/api/v1/user/test',
|
||||||
|
'/api/v1/webhooks',
|
||||||
'/api/v1/oauth2-credential/callback',
|
'/api/v1/oauth2-credential/callback',
|
||||||
'/api/v1/oauth2-credential/refresh',
|
'/api/v1/oauth2-credential/refresh',
|
||||||
AzureSSO.LOGIN_URI,
|
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 { StatusCodes } from 'http-status-codes'
|
||||||
import { InternalFlowiseError } from '../errors/internalFlowiseError'
|
import { InternalFlowiseError } from '../errors/internalFlowiseError'
|
||||||
import { UsageCacheManager } from '../UsageCacheManager'
|
import { UsageCacheManager } from '../UsageCacheManager'
|
||||||
|
import { StripeManager } from '../StripeManager'
|
||||||
import { LICENSE_QUOTAS } from './constants'
|
import { LICENSE_QUOTAS } from './constants'
|
||||||
import logger from './logger'
|
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
|
// As predictions limit renew per month, we set to cache with 1 month TTL
|
||||||
export const updatePredictionsUsage = async (
|
export const updatePredictionsUsage = async (
|
||||||
orgId: string,
|
orgId: string,
|
||||||
|
|
@ -125,22 +179,31 @@ export const updatePredictionsUsage = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkPredictions = async (orgId: string, subscriptionId: string, usageCacheManager: UsageCacheManager) => {
|
export const updatePredictionsUsageWithCredits = async (
|
||||||
if (!usageCacheManager || !subscriptionId) return
|
orgId: string,
|
||||||
|
subscriptionId: string,
|
||||||
|
useCredits: boolean = false,
|
||||||
|
usageCacheManager?: UsageCacheManager
|
||||||
|
) => {
|
||||||
|
if (!usageCacheManager) return
|
||||||
|
|
||||||
const currentPredictions: number = (await usageCacheManager.get(`predictions:${orgId}`)) || 0
|
if (useCredits) {
|
||||||
|
// Report meter usage for credit billing
|
||||||
const quotas = await usageCacheManager.getQuotas(subscriptionId)
|
try {
|
||||||
const predictionsLimit = quotas[LICENSE_QUOTAS.PREDICTIONS_LIMIT]
|
const stripeManager = await StripeManager.getInstance()
|
||||||
if (predictionsLimit === -1) return
|
if (stripeManager) {
|
||||||
|
const subscriptionDetails = await usageCacheManager.getSubscriptionDetails(subscriptionId)
|
||||||
if (currentPredictions >= predictionsLimit) {
|
logger.info(`subscription details: ${JSON.stringify(subscriptionDetails)}`)
|
||||||
throw new InternalFlowiseError(StatusCodes.TOO_MANY_REQUESTS, 'Predictions limit exceeded')
|
if (subscriptionDetails && subscriptionDetails.customer) {
|
||||||
}
|
await stripeManager.reportMeterUsage(subscriptionDetails.customer as string)
|
||||||
|
}
|
||||||
return {
|
}
|
||||||
usage: currentPredictions,
|
} catch (error) {
|
||||||
limit: predictionsLimit
|
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 getCustomerDefaultSource = (customerId) => client.get(`/organization/customer-default-source?customerId=${customerId}`)
|
||||||
const getAdditionalSeatsProration = (subscriptionId, quantity) =>
|
const getAdditionalSeatsProration = (subscriptionId, quantity) =>
|
||||||
client.get(`/organization/additional-seats-proration?subscriptionId=${subscriptionId}&quantity=${quantity}`)
|
client.get(`/organization/additional-seats-proration?subscriptionId=${subscriptionId}&quantity=${quantity}`)
|
||||||
const updateAdditionalSeats = (subscriptionId, quantity, prorationDate) =>
|
const updateAdditionalSeats = (subscriptionId, quantity, prorationDate, increase) =>
|
||||||
client.post(`/organization/update-additional-seats`, { subscriptionId, quantity, prorationDate })
|
client.post(`/organization/update-additional-seats`, { subscriptionId, quantity, prorationDate, increase })
|
||||||
const getPlanProration = (subscriptionId, newPlanId) =>
|
const getPlanProration = (subscriptionId, newPlanId) =>
|
||||||
client.get(`/organization/plan-proration?subscriptionId=${subscriptionId}&newPlanId=${newPlanId}`)
|
client.get(`/organization/plan-proration?subscriptionId=${subscriptionId}&newPlanId=${newPlanId}`)
|
||||||
const updateSubscriptionPlan = (subscriptionId, newPlanId, prorationDate) =>
|
const updateSubscriptionPlan = (subscriptionId, newPlanId, prorationDate) =>
|
||||||
client.post(`/organization/update-subscription-plan`, { subscriptionId, newPlanId, prorationDate })
|
client.post(`/organization/update-subscription-plan`, { subscriptionId, newPlanId, prorationDate })
|
||||||
const getCurrentUsage = () => client.get(`/organization/get-current-usage`)
|
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
|
// workspace users
|
||||||
const getAllUsersByWorkspaceId = (workspaceId) => client.get(`/workspaceuser?workspaceId=${workspaceId}`)
|
const getAllUsersByWorkspaceId = (workspaceId) => client.get(`/workspaceuser?workspaceId=${workspaceId}`)
|
||||||
|
|
@ -55,5 +60,10 @@ export default {
|
||||||
getPlanProration,
|
getPlanProration,
|
||||||
updateSubscriptionPlan,
|
updateSubscriptionPlan,
|
||||||
getCurrentUsage,
|
getCurrentUsage,
|
||||||
|
getPredictionEligibility,
|
||||||
|
purchaseCredits,
|
||||||
|
getCreditsBalance,
|
||||||
|
getUsageWithCredits,
|
||||||
|
getCreditsPackages,
|
||||||
deleteOrganizationUser
|
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)
|
setData(result.data)
|
||||||
setError(null)
|
setError(null)
|
||||||
setApiError(null)
|
setApiError(null)
|
||||||
|
return result // Return the full response for payment failure handling
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err || 'Unexpected Error!')
|
handleError(err || 'Unexpected Error!')
|
||||||
setApiError(err || 'Unexpected Error!')
|
setApiError(err || 'Unexpected Error!')
|
||||||
|
throw err // Re-throw error to maintain existing error handling
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ import Header from './Header'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import { drawerWidth, headerHeight } from '@/store/constant'
|
import { drawerWidth, headerHeight } from '@/store/constant'
|
||||||
import { SET_MENU } from '@/store/actions'
|
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
|
// styles
|
||||||
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({
|
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({
|
||||||
|
|
@ -60,6 +68,10 @@ const MainLayout = () => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const matchDownMd = useMediaQuery(theme.breakpoints.down('lg'))
|
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
|
// Handle left drawer
|
||||||
const leftDrawerOpened = useSelector((state) => state.customization.opened)
|
const leftDrawerOpened = useSelector((state) => state.customization.opened)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
@ -67,6 +79,24 @@ const MainLayout = () => {
|
||||||
dispatch({ type: SET_MENU, opened: !leftDrawerOpened })
|
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(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => dispatch({ type: SET_MENU, opened: !matchDownMd }), 0)
|
setTimeout(() => dispatch({ type: SET_MENU, opened: !matchDownMd }), 0)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,46 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
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 ||============================== //
|
// ==============================|| MINIMAL LAYOUT ||============================== //
|
||||||
|
|
||||||
const MinimalLayout = () => (
|
const MinimalLayout = () => {
|
||||||
<>
|
// authenticated user
|
||||||
<Outlet />
|
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
|
export default MinimalLayout
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,30 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Navigate } from 'react-router'
|
import { Navigate } from 'react-router'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useConfig } from '@/store/context/ConfigContext'
|
import { useConfig } from '@/store/context/ConfigContext'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
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
|
* Checks if a feature flag is enabled
|
||||||
|
|
@ -28,13 +49,117 @@ const checkFeatureFlag = (features, display, children) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RequireAuth = ({ permission, display, children }) => {
|
export const RequireAuth = ({ permission, display, children }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const dispatch = useDispatch()
|
||||||
const { isCloud, isOpenSource, isEnterpriseLicensed } = useConfig()
|
const { isCloud, isOpenSource, isEnterpriseLicensed } = useConfig()
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const isGlobal = useSelector((state) => state.auth.isGlobal)
|
const isGlobal = useSelector((state) => state.auth.isGlobal)
|
||||||
const currentUser = useSelector((state) => state.auth.user)
|
const currentUser = useSelector((state) => state.auth.user)
|
||||||
const features = useSelector((state) => state.auth.features)
|
const features = useSelector((state) => state.auth.features)
|
||||||
const permissions = useSelector((state) => state.auth.permissions)
|
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
|
// Step 1: Authentication Check
|
||||||
// Redirect to login if user is not authenticated
|
// 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
|
// Cloud & Enterprise: Check both permissions and feature flags
|
||||||
if (isCloud || isEnterpriseLicensed) {
|
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)
|
// Allow access to basic features (no display property)
|
||||||
if (!display) return children
|
if (!display) return children
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,21 @@ const authSlice = createSlice({
|
||||||
})
|
})
|
||||||
state.user.assignedWorkspaces = assignedWorkspaces
|
state.user.assignedWorkspaces = assignedWorkspaces
|
||||||
AuthUtils.updateCurrentUser(state.user)
|
AuthUtils.updateCurrentUser(state.user)
|
||||||
|
},
|
||||||
|
organizationUpdated: (state, action) => {
|
||||||
|
const organization = action.payload
|
||||||
|
state.organization = organization
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { loginSuccess, logoutSuccess, workspaceSwitchSuccess, upgradePlanSuccess, userProfileUpdated, workspaceNameUpdated } =
|
export const {
|
||||||
authSlice.actions
|
loginSuccess,
|
||||||
|
logoutSuccess,
|
||||||
|
workspaceSwitchSuccess,
|
||||||
|
upgradePlanSuccess,
|
||||||
|
userProfileUpdated,
|
||||||
|
workspaceNameUpdated,
|
||||||
|
organizationUpdated
|
||||||
|
} = authSlice.actions
|
||||||
export default authSlice.reducer
|
export default authSlice.reducer
|
||||||
|
|
|
||||||
|
|
@ -88,23 +88,53 @@ const PricingDialog = ({ open, onClose }) => {
|
||||||
prorationInfo.prorationDate
|
prorationInfo.prorationDate
|
||||||
)
|
)
|
||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
// Subscription updated successfully
|
// Check if payment failed but plan was upgraded (Issue #4 fix)
|
||||||
store.dispatch(upgradePlanSuccess(response.data.user))
|
if (response.data.paymentFailed) {
|
||||||
enqueueSnackbar('Subscription updated successfully!', { variant: 'success' })
|
// Subscription updated but payment failed
|
||||||
onClose(true)
|
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 {
|
} else {
|
||||||
const errorMessage = response.data.message || 'Subscription failed to update'
|
const errorMessage = response.data.message || 'Subscription failed to update'
|
||||||
enqueueSnackbar(errorMessage, { variant: 'error' })
|
enqueueSnackbar(errorMessage, { variant: 'error' })
|
||||||
onClose()
|
// Delay closing to allow user to see the error message
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenPlanDialog(false)
|
||||||
|
onClose()
|
||||||
|
}, 3000)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating plan:', 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' })
|
enqueueSnackbar(errorMessage, { variant: 'error' })
|
||||||
onClose()
|
// Delay closing to allow user to see the error message
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenPlanDialog(false)
|
||||||
|
onClose()
|
||||||
|
}, 3000)
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdatingPlan(false)
|
setIsUpdatingPlan(false)
|
||||||
setOpenPlanDialog(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,8 +553,8 @@ const PricingDialog = ({ open, onClose }) => {
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
bgcolor: 'warning.light',
|
bgcolor: customization.isDarkMode ? 'success.dark' : 'success.light',
|
||||||
color: 'warning.dark',
|
color: customization.isDarkMode ? 'success.light' : 'success.dark',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import SettingsSection from '@/ui-component/form/settings'
|
||||||
import PricingDialog from '@/ui-component/subscription/PricingDialog'
|
import PricingDialog from '@/ui-component/subscription/PricingDialog'
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import { IconAlertCircle, IconCreditCard, IconExternalLink, IconSparkles, IconX } from '@tabler/icons-react'
|
import { IconAlertCircle, IconCoins, IconCreditCard, IconExternalLink, IconSparkles, IconX } from '@tabler/icons-react'
|
||||||
|
|
||||||
// API
|
// API
|
||||||
import accountApi from '@/api/account.api'
|
import accountApi from '@/api/account.api'
|
||||||
|
|
@ -57,6 +57,29 @@ const calculatePercentage = (count, total) => {
|
||||||
return Math.min((count / total) * 100, 100)
|
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 AccountSettings = () => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
@ -88,6 +111,23 @@ const AccountSettings = () => {
|
||||||
const [purchasedSeats, setPurchasedSeats] = useState(0)
|
const [purchasedSeats, setPurchasedSeats] = useState(0)
|
||||||
const [occupiedSeats, setOccupiedSeats] = useState(0)
|
const [occupiedSeats, setOccupiedSeats] = useState(0)
|
||||||
const [totalSeats, setTotalSeats] = 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(() => {
|
const predictionsUsageInPercent = useMemo(() => {
|
||||||
return usage ? calculatePercentage(usage.predictions?.usage, usage.predictions?.limit) : 0
|
return usage ? calculatePercentage(usage.predictions?.usage, usage.predictions?.limit) : 0
|
||||||
|
|
@ -106,6 +146,11 @@ const AccountSettings = () => {
|
||||||
const getCustomerDefaultSourceApi = useApi(userApi.getCustomerDefaultSource)
|
const getCustomerDefaultSourceApi = useApi(userApi.getCustomerDefaultSource)
|
||||||
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
|
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
|
||||||
const getCurrentUsageApi = useApi(userApi.getCurrentUsage)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
|
|
@ -113,6 +158,10 @@ const AccountSettings = () => {
|
||||||
getPricingPlansApi.request()
|
getPricingPlansApi.request()
|
||||||
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
|
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
|
||||||
getCurrentUsageApi.request()
|
getCurrentUsageApi.request()
|
||||||
|
getCreditsBalanceApi.request()
|
||||||
|
getCreditsPackagesApi.request()
|
||||||
|
getUsageWithCreditsApi.request()
|
||||||
|
getPredictionEligibilityApi.request()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isCloud])
|
}, [isCloud])
|
||||||
|
|
@ -140,13 +189,25 @@ const AccountSettings = () => {
|
||||||
}, [getCurrentUsageApi.data])
|
}, [getCurrentUsageApi.data])
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
setSeatsQuantity(0)
|
||||||
getCustomerDefaultSourceApi.request(currentUser?.activeOrganizationCustomerId)
|
getCustomerDefaultSourceApi.request(currentUser?.activeOrganizationCustomerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openRemoveSeatsDialog, openAddSeatsDialog])
|
}, [openRemoveSeatsDialog, openAddSeatsDialog, openCreditsDialog])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getAdditionalSeatsProrationApi.data) {
|
if (getAdditionalSeatsProrationApi.data) {
|
||||||
|
|
@ -347,7 +408,7 @@ const AccountSettings = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSeatsModification = async (newSeatsAmount) => {
|
const handleSeatsModification = async (newSeatsAmount, increase) => {
|
||||||
try {
|
try {
|
||||||
setIsUpdatingSeats(true)
|
setIsUpdatingSeats(true)
|
||||||
|
|
||||||
|
|
@ -355,23 +416,45 @@ const AccountSettings = () => {
|
||||||
throw new Error('No proration date available')
|
throw new Error('No proration date available')
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAdditionalSeatsApi.request(
|
const response = await updateAdditionalSeatsApi.request(
|
||||||
currentUser?.activeOrganizationSubscriptionId,
|
currentUser?.activeOrganizationSubscriptionId,
|
||||||
newSeatsAmount,
|
newSeatsAmount,
|
||||||
prorationInfo.prorationDate
|
prorationInfo.prorationDate,
|
||||||
|
increase
|
||||||
)
|
)
|
||||||
enqueueSnackbar({
|
|
||||||
message: 'Seats updated successfully',
|
// Check if payment failed but seats were updated (Issue #4 fix)
|
||||||
options: {
|
if (response.data?.paymentFailed) {
|
||||||
key: new Date().getTime() + Math.random(),
|
// Seats updated but payment failed
|
||||||
variant: 'success',
|
const paymentErrorMessage = response.data.paymentError || 'Payment failed'
|
||||||
action: (key) => (
|
enqueueSnackbar({
|
||||||
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
|
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.`,
|
||||||
<IconX />
|
options: {
|
||||||
</Button>
|
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
|
// Refresh the seats quantity display
|
||||||
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
|
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
|
||||||
} catch (error) {
|
} 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
|
// Calculate empty seats
|
||||||
const emptySeats = Math.min(purchasedSeats, totalSeats - occupiedSeats)
|
const emptySeats = Math.min(purchasedSeats, totalSeats - occupiedSeats)
|
||||||
|
|
||||||
|
|
@ -704,6 +844,74 @@ const AccountSettings = () => {
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</SettingsSection>
|
</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
|
<SettingsSection
|
||||||
action={
|
action={
|
||||||
<StyledButton onClick={saveProfileData} sx={{ borderRadius: 2, height: 40 }} variant='contained'>
|
<StyledButton onClick={saveProfileData} sx={{ borderRadius: 2, height: 40 }} variant='contained'>
|
||||||
|
|
@ -1160,7 +1368,7 @@ const AccountSettings = () => {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity)}
|
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity, false)}
|
||||||
disabled={
|
disabled={
|
||||||
getCustomerDefaultSourceApi.loading ||
|
getCustomerDefaultSourceApi.loading ||
|
||||||
!getCustomerDefaultSourceApi.data ||
|
!getCustomerDefaultSourceApi.data ||
|
||||||
|
|
@ -1414,7 +1622,7 @@ const AccountSettings = () => {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats)}
|
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats, true)}
|
||||||
disabled={
|
disabled={
|
||||||
getCustomerDefaultSourceApi.loading ||
|
getCustomerDefaultSourceApi.loading ||
|
||||||
!getCustomerDefaultSourceApi.data ||
|
!getCustomerDefaultSourceApi.data ||
|
||||||
|
|
@ -1435,6 +1643,167 @@ const AccountSettings = () => {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</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>
|
</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