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