diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx
index 246891d84b3..e4130018b05 100644
--- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx
@@ -1,13 +1,13 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { Plan } from '@/app/components/billing/type'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { segmentImportStatus } from '@/types/dataset'
import { SegmentAdd } from '../index'
// Mock provider context
-let mockPlan = { type: Plan.professional }
+let mockPlan: { type: CloudPlan } = { type: 'professional' }
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@@ -19,7 +19,7 @@ vi.mock('@/context/provider-context', () => ({
describe('SegmentAdd', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockPlan = { type: Plan.professional }
+ mockPlan = { type: 'professional' }
mockEnableBilling = true
})
@@ -141,7 +141,7 @@ describe('SegmentAdd', () => {
})
it('should show plan upgrade modal instead of batch modal for sandbox users', async () => {
- mockPlan = { type: Plan.sandbox }
+ mockPlan = { type: 'sandbox' }
const mockShowBatchModal = vi.fn()
render(
)
@@ -172,7 +172,7 @@ describe('SegmentAdd', () => {
// Plan upgrade modal
describe('Plan Upgrade Modal', () => {
it('should show plan upgrade modal when sandbox user tries to add', () => {
- mockPlan = { type: Plan.sandbox }
+ mockPlan = { type: 'sandbox' }
render(
)
fireEvent.click(screen.getByText(/list\.action\.addButton/i))
@@ -181,7 +181,7 @@ describe('SegmentAdd', () => {
})
it('should not call showNewSegmentModal for sandbox users', () => {
- mockPlan = { type: Plan.sandbox }
+ mockPlan = { type: 'sandbox' }
const mockShowNewSegmentModal = vi.fn()
render(
)
@@ -191,7 +191,7 @@ describe('SegmentAdd', () => {
})
it('should allow add when billing is disabled regardless of plan', () => {
- mockPlan = { type: Plan.sandbox }
+ mockPlan = { type: 'sandbox' }
mockEnableBilling = false
const mockShowNewSegmentModal = vi.fn()
render(
)
@@ -202,7 +202,7 @@ describe('SegmentAdd', () => {
})
it('should close plan upgrade modal when close button is clicked', () => {
- mockPlan = { type: Plan.sandbox }
+ mockPlan = { type: 'sandbox' }
render(
)
// Show modal
diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx
index 6eb85e42cb6..aff4c7582fe 100644
--- a/web/app/components/datasets/documents/detail/segment-add/index.tsx
+++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx
@@ -10,7 +10,6 @@ import {
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
-import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
import { segmentImportStatus } from '@/types/dataset'
@@ -32,7 +31,7 @@ export function SegmentAdd({
const { t } = useTranslation()
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
const { plan, enableBilling } = useProviderContext()
- const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
+ const canAddChunks = !enableBilling || plan.type !== 'sandbox'
const textColor = embedding
? 'text-components-button-secondary-accent-text-disabled'
diff --git a/web/app/components/develop/__tests__/workflow-version-api-upgrade-notice.spec.tsx b/web/app/components/develop/__tests__/workflow-version-api-upgrade-notice.spec.tsx
index 3aa8643c7bf..517deb775a3 100644
--- a/web/app/components/develop/__tests__/workflow-version-api-upgrade-notice.spec.tsx
+++ b/web/app/components/develop/__tests__/workflow-version-api-upgrade-notice.spec.tsx
@@ -1,11 +1,11 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import { fireEvent, render, screen, within } from '@testing-library/react'
-import { Plan } from '@/app/components/billing/type'
import {
WorkflowVersionApiContent,
WorkflowVersionApiUpgradeNotice,
} from '../workflow-version-api-upgrade-notice'
-let mockPlanType = Plan.professional
+let mockPlanType: CloudPlan = 'professional'
let mockEnableBilling = true
let mockIsFetchedPlan = true
@@ -69,13 +69,13 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({
describe('WorkflowVersionApiUpgradeNotice', () => {
beforeEach(() => {
- mockPlanType = Plan.professional
+ mockPlanType = 'professional'
mockEnableBilling = true
mockIsFetchedPlan = true
})
it('should not render before the plan is fetched', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
mockIsFetchedPlan = false
render(
)
@@ -86,7 +86,7 @@ describe('WorkflowVersionApiUpgradeNotice', () => {
})
it('should not render when billing is disabled', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
mockEnableBilling = false
render(
)
@@ -105,7 +105,7 @@ describe('WorkflowVersionApiUpgradeNotice', () => {
})
it('should show a small upgrade button and open the plan upgrade modal for sandbox plans', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
render(
)
@@ -133,7 +133,7 @@ describe('WorkflowVersionApiUpgradeNotice', () => {
describe('WorkflowVersionApiContent', () => {
beforeEach(() => {
- mockPlanType = Plan.professional
+ mockPlanType = 'professional'
mockEnableBilling = true
mockIsFetchedPlan = true
})
@@ -147,7 +147,7 @@ describe('WorkflowVersionApiContent', () => {
})
it('should hide content for sandbox plans', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
const { container } = render(
<>
@@ -168,7 +168,7 @@ describe('WorkflowVersionApiContent', () => {
})
it('should show content when billing is disabled', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
mockEnableBilling = false
mockIsFetchedPlan = false
diff --git a/web/app/components/develop/workflow-version-api-upgrade-notice.tsx b/web/app/components/develop/workflow-version-api-upgrade-notice.tsx
index ee18b30a2ca..9791a4aeb00 100644
--- a/web/app/components/develop/workflow-version-api-upgrade-notice.tsx
+++ b/web/app/components/develop/workflow-version-api-upgrade-notice.tsx
@@ -4,14 +4,13 @@ import type { PropsWithChildren } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
-import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useProviderContext } from '@/context/provider-context'
export const WorkflowVersionApiContent = ({ children }: PropsWithChildren) => {
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
- if (enableBilling && (!isFetchedPlan || plan.type === Plan.sandbox)) return
+ if (enableBilling && (!isFetchedPlan || plan.type === 'sandbox')) return
return children
}
@@ -21,7 +20,7 @@ export const WorkflowVersionApiUpgradeNotice = () => {
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
- if (!isFetchedPlan || !enableBilling || plan.type !== Plan.sandbox) return null
+ if (!isFetchedPlan || !enableBilling || plan.type !== 'sandbox') return null
const title = t(($) => $['upgrade.workflowVersionRun.title'])
const description = t(($) => $['upgrade.workflowVersionRun.description'])
diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
index 666ebaf8cb8..6c94b14ae0f 100644
--- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
+++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
@@ -1,3 +1,4 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { CreateAppModalProps } from '../index'
import type { UsagePlanInfo } from '@/app/components/billing/type'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -7,7 +8,6 @@ import {
createMockPlanTotal,
createMockPlanUsage,
} from '@/__mocks__/provider-context'
-import { Plan } from '@/app/components/billing/type'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import CreateAppModal from '../index'
@@ -57,7 +57,7 @@ const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
})
let mockEnableBilling = false
-let mockPlanType: Plan = Plan.team
+let mockPlanType: CloudPlan = 'team'
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
@@ -118,7 +118,7 @@ describe('CreateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnableBilling = false
- mockPlanType = Plan.team
+ mockPlanType = 'team'
mockUsagePlanInfo = createPlanInfo(1)
mockTotalPlanInfo = createPlanInfo(10)
hotkeyMocks.handlers.clear()
@@ -223,7 +223,7 @@ describe('CreateAppModal', () => {
describe('Quota Gating', () => {
it('should show AppsFull and disable create when apps quota is reached', async () => {
mockEnableBilling = true
- mockPlanType = Plan.team
+ mockPlanType = 'team'
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
@@ -235,7 +235,7 @@ describe('CreateAppModal', () => {
it('should allow saving when apps quota is reached in edit mode', async () => {
mockEnableBilling = true
- mockPlanType = Plan.team
+ mockPlanType = 'team'
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
@@ -281,7 +281,7 @@ describe('CreateAppModal', () => {
it('should not submit when apps quota is reached in create mode', async () => {
mockEnableBilling = true
- mockPlanType = Plan.team
+ mockPlanType = 'team'
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
@@ -298,7 +298,7 @@ describe('CreateAppModal', () => {
it('should submit when apps quota is reached in edit mode', async () => {
mockEnableBilling = true
- mockPlanType = Plan.team
+ mockPlanType = 'team'
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
diff --git a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx
index e653663f9cb..d884e22e7d7 100644
--- a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx
+++ b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx
@@ -7,7 +7,6 @@ import {
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
@@ -65,7 +64,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.sandbox,
+ type: 'sandbox',
},
})
vi.mocked(useModalContext).mockReturnValue({
@@ -136,7 +135,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.team,
+ type: 'team',
},
})
@@ -157,7 +156,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.team,
+ type: 'team',
},
})
@@ -181,7 +180,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.team,
+ type: 'team',
},
})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -216,7 +215,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.professional,
+ type: 'professional',
},
})
@@ -244,7 +243,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.team,
+ type: 'team',
},
})
@@ -284,7 +283,7 @@ describe('Compliance', () => {
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
- type: Plan.team,
+ type: 'team',
},
})
@@ -319,24 +318,5 @@ describe('Compliance', () => {
// getDocDownloadUrl should still have only been called once
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, 20000)
-
- // canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
- it('should show upgrade badge with empty tooltip for enterprise plan', () => {
- // Arrange
- vi.mocked(useProviderContext).mockReturnValue({
- ...baseProviderContextValue,
- plan: {
- ...baseProviderContextValue.plan,
- type: Plan.enterprise,
- },
- })
-
- // Act
- openMenuAndRender()
-
- // Assert - enterprise is not in any download list, so upgrade badges should appear
- // The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
- expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
- })
})
})
diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx
index 3865e84545a..fc7fdd3a730 100644
--- a/web/app/components/header/account-dropdown/compliance.tsx
+++ b/web/app/components/header/account-dropdown/compliance.tsx
@@ -1,3 +1,4 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
@@ -13,7 +14,6 @@ import { useMutation } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import { Plan } from '@/app/components/billing/type'
import {
settingsQueryParamName,
settingsQueryParser,
@@ -101,7 +101,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
const { plan } = useProviderContext()
const { setShowPricingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
- const isFreePlan = plan.type === Plan.sandbox
+ const isFreePlan = plan.type === 'sandbox'
const { isPending, mutate: downloadCompliance } = useMutation({
mutationKey: ['downloadCompliance', docName],
@@ -117,11 +117,11 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
},
})
- const whichPlanCanDownloadCompliance = {
- [DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
- [DocName.SOC2_Type_II]: [Plan.team],
- [DocName.ISO_27001]: [Plan.team],
- [DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
+ const whichPlanCanDownloadCompliance: Record
= {
+ [DocName.SOC2_Type_I]: ['professional', 'team'],
+ [DocName.SOC2_Type_II]: ['team'],
+ [DocName.ISO_27001]: ['team'],
+ [DocName.GDPR]: ['team', 'professional', 'sandbox'],
}
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
@@ -143,11 +143,10 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
setShowPricingModal,
])
- const upgradeTooltip: Record = {
- [Plan.sandbox]: t(($) => $['compliance.sandboxUpgradeTooltip'], { ns: 'common' }),
- [Plan.professional]: t(($) => $['compliance.professionalUpgradeTooltip'], { ns: 'common' }),
- [Plan.team]: '',
- [Plan.enterprise]: '',
+ const upgradeTooltip: Record = {
+ sandbox: t(($) => $['compliance.sandboxUpgradeTooltip'], { ns: 'common' }),
+ professional: t(($) => $['compliance.professionalUpgradeTooltip'], { ns: 'common' }),
+ team: '',
}
const labelTitle = typeof label === 'string' ? label : undefined
diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx
index ecd2b2d2759..f50fbb9f462 100644
--- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx
+++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx
@@ -9,7 +9,6 @@ import {
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
-import { Plan } from '@/app/components/billing/type'
import { PlanBadge } from '@/app/components/header/plan-badge'
type WorkplaceSelectorContentProps = {
@@ -21,15 +20,9 @@ type WorkplaceSelectorItemProps = {
workspace: TenantListItemResponse
}
-const workspacePlans = new Set(Object.values(Plan))
-
-function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
- return !!plan && workspacePlans.has(plan)
-}
-
const WorkplaceSelectorItem = memo(({ workspace }: WorkplaceSelectorItemProps) => {
const workspaceName = workspace.name || workspace.id
- const workspacePlan = isWorkspacePlan(workspace.plan) ? workspace.plan : Plan.sandbox
+ const workspacePlan = workspace.plan ?? 'sandbox'
return (
diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx
index 6a647de57e3..c7f1d89c72b 100644
--- a/web/app/components/header/account-setting/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx
@@ -145,7 +145,7 @@ const baseConsoleState: ConsoleStateFixture = {
currentWorkspace: {
id: '1',
name: 'Workspace',
- plan: '',
+ plan: null,
role: 'owner',
},
isCurrentWorkspaceManager: true,
diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
index d7e6a64cff0..69fbea58f84 100644
--- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
@@ -6,7 +6,6 @@ import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
-import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
@@ -399,7 +398,7 @@ describe('MembersPage', () => {
createMockProviderContextValue({
enableBilling: true,
plan: {
- type: Plan.sandbox,
+ type: 'sandbox',
total: { teamMembers: 5 } as unknown as ReturnType<
typeof useProviderContext
>['plan']['total'],
@@ -420,7 +419,7 @@ describe('MembersPage', () => {
createMockProviderContextValue({
enableBilling: true,
plan: {
- type: Plan.sandbox,
+ type: 'sandbox',
total: { teamMembers: -1 } as unknown as ReturnType<
typeof useProviderContext
>['plan']['total'],
@@ -438,7 +437,7 @@ describe('MembersPage', () => {
createMockProviderContextValue({
enableBilling: true,
plan: {
- type: Plan.team,
+ type: 'team',
total: { teamMembers: 50 } as unknown as ReturnType<
typeof useProviderContext
>['plan']['total'],
@@ -448,8 +447,8 @@ describe('MembersPage', () => {
renderMembersPage()
- // Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
- // Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
+ // 'team' is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
+ // 'team' is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
expect(screen.getByText(/plansCommon\.memberAfter/i))!.toBeInTheDocument()
})
@@ -548,7 +547,7 @@ describe('MembersPage', () => {
createMockProviderContextValue({
enableBilling: true,
plan: {
- type: Plan.sandbox,
+ type: 'sandbox',
total: { teamMembers: 5 } as unknown as ReturnType<
typeof useProviderContext
>['plan']['total'],
@@ -719,7 +718,7 @@ describe('MembersPage', () => {
createMockProviderContextValue({
enableBilling: true,
plan: {
- type: Plan.sandbox,
+ type: 'sandbox',
total: { teamMembers: 2 } as unknown as ReturnType<
typeof useProviderContext
>['plan']['total'],
diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx
index 9f10af7470d..10023f4ae62 100644
--- a/web/app/components/header/account-setting/members-page/index.tsx
+++ b/web/app/components/header/account-setting/members-page/index.tsx
@@ -10,7 +10,6 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
import { NUM_INFINITE } from '@/app/components/billing/config'
-import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useLocale } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
@@ -50,8 +49,7 @@ const MembersPage = () => {
>(null)
const accounts = data?.accounts || []
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
- const isNotUnlimitedMemberPlan =
- enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise
+ const isNotUnlimitedMemberPlan = enableBilling && plan.type !== 'team'
const isMemberFull =
enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx
index 0b690bab975..0096ab1afb1 100644
--- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx
@@ -1,21 +1,28 @@
+import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
-import { useProviderContextSelector } from '@/context/provider-context'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
+import { seedFeatures } from '@/test/console/query-data'
import { InviteModal } from '../index'
-const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() }))
-
-vi.mock('@/context/provider-context', () => ({
- useProviderContextSelector: vi.fn(),
+const { fetchFeatures, inviteMember } = vi.hoisted(() => ({
+ fetchFeatures: vi.fn(),
+ inviteMember: vi.fn(),
}))
+
vi.mock('@/service/access-control/use-workspace-roles')
vi.mock('@/service/client', () => ({
consoleQuery: {
+ features: {
+ get: {
+ queryKey: () => ['features'],
+ queryOptions: () => ({ queryKey: ['features'], queryFn: fetchFeatures }),
+ },
+ },
workspaces: {
current: {
members: {
@@ -33,7 +40,6 @@ vi.mock('@/service/client', () => ({
describe('InviteModal', () => {
const onOpenChange = vi.fn()
const onSend = vi.fn()
- const refreshLicenseLimit = vi.fn()
const createQueryClient = () =>
new QueryClient({
@@ -89,24 +95,23 @@ describe('InviteModal', () => {
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
} as unknown as ReturnType)
- vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
- selector({
- licenseLimit: { workspace_members: { size: 5, limit: 10 } },
- refreshLicenseLimit,
- } as unknown as Parameters[0]),
- )
})
const renderModal = ({
open = true,
isEmailSetup = true,
queryClient = createQueryClient(),
+ workspaceMembers = { enabled: true, size: 5, limit: 10 },
}: {
open?: boolean
isEmailSetup?: boolean
queryClient?: QueryClient
- } = {}) =>
- render(
+ workspaceMembers?: GetFeaturesResponse['workspace_members']
+ } = {}) => {
+ const features = seedFeatures(queryClient, { workspace_members: workspaceMembers })
+ fetchFeatures.mockResolvedValue(features)
+
+ return render(
{
/>
,
)
+ }
const selectAdminRole = async (user: ReturnType) => {
await user.click(screen.getByRole('combobox', { name: /members\.role/i }))
@@ -181,7 +187,9 @@ describe('InviteModal', () => {
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
- renderModal()
+ const queryClient = createQueryClient()
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
+ renderModal({ queryClient })
await addRecipients(user, 'First@Example.com, second@example.com; first@example.com')
await selectAdminRole(user)
@@ -196,7 +204,7 @@ describe('InviteModal', () => {
},
})
})
- expect(refreshLicenseLimit).toHaveBeenCalled()
+ expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['features'] })
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onSend).toHaveBeenCalledWith([])
})
@@ -466,18 +474,12 @@ describe('InviteModal', () => {
it('warns but lets the backend decide whether recipients consume remaining seats', async () => {
const user = userEvent.setup()
- vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
- selector({
- licenseLimit: { workspace_members: { size: 9, limit: 10 } },
- refreshLicenseLimit,
- } as unknown as Parameters[0]),
- )
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
- renderModal()
+ renderModal({ workspaceMembers: { enabled: true, size: 9, limit: 10 } })
await addRecipients(user, 'one@example.com, two@example.com')
await selectAdminRole(user)
@@ -491,13 +493,7 @@ describe('InviteModal', () => {
it('counts a manually typed recipient list before it is committed', async () => {
const user = userEvent.setup()
- vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
- selector({
- licenseLimit: { workspace_members: { size: 9, limit: 10 } },
- refreshLicenseLimit,
- } as unknown as Parameters[0]),
- )
- renderModal()
+ renderModal({ workspaceMembers: { enabled: true, size: 9, limit: 10 } })
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'one@example.com,two@example.com')
@@ -662,6 +658,8 @@ describe('InviteModal', () => {
it('resets the form after a controlled close', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
+ const features = seedFeatures(queryClient)
+ fetchFeatures.mockResolvedValue(features)
const ControlledInviteModal = () => {
const [open, setOpen] = useState(false)
diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
index 6d848f37547..1fafaa0b12f 100644
--- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
+++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
@@ -13,11 +13,10 @@ import {
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
-import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
-import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { commonQueryKeys } from '@/service/use-common'
import { mergeEmailRecipients } from './email-recipients'
@@ -49,15 +48,18 @@ function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
const { t } = useTranslation()
const locale = useLocale()
const queryClient = useQueryClient()
- const licenseLimit = useProviderContextSelector((state) => state.licenseLimit)
- const refreshLicenseLimit = useProviderContextSelector((state) => state.refreshLicenseLimit)
+ const { data: features } = useQuery(consoleQuery.features.get.queryOptions())
const [recipients, setRecipients] = useState([])
const [draft, setDraft] = useState('')
const [submissionError, setSubmissionError] = useState(null)
const fieldErrors = submissionError?.kind === 'fields' ? submissionError.errors : undefined
- const currentSize = licenseLimit.workspace_members.size ?? 0
- const memberLimit = licenseLimit.workspace_members.limit
- const remainingSeats = memberLimit > 0 ? Math.max(memberLimit - currentSize, 0) : null
+ const memberLimit = features?.workspace_members.enabled
+ ? features.workspace_members
+ : features?.billing.enabled && features.members.limit > 0
+ ? features.members
+ : undefined
+ const remainingSeats =
+ memberLimit && memberLimit.limit > 0 ? Math.max(memberLimit.limit - memberLimit.size, 0) : null
const effectiveRecipients = mergeEmailRecipients(recipients, draft)
const validRecipientCount = effectiveRecipients.filter(({ isValid }) => isValid).length
const exceedsRemainingSeats = remainingSeats !== null && validRecipientCount > remainingSeats
@@ -88,7 +90,7 @@ function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
},
{
onSuccess: (response) => {
- refreshLicenseLimit()
+ void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() })
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
onOpenChange(false)
onSend(response.invitation_results)
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx
index 5fb62233f84..c2f35ad934e 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx
@@ -228,7 +228,7 @@ describe('ModelListItem', () => {
expect(badge).toBeInTheDocument()
})
- // Plan.sandbox: ConfigModel shown without load balancing enabled
+ // 'sandbox': ConfigModel shown without load balancing enabled
it('should show ConfigModel for sandbox plan even without load balancing enabled', () => {
// Arrange - set plan type to sandbox and keep load balancing disabled
mockModelLoadBalancingEnabled = false
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx
index 5df9fb0b02a..8445e733fc2 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx
@@ -10,7 +10,6 @@ import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
-import { Plan } from '@/app/components/billing/type'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
@@ -132,7 +131,7 @@ const ModelListItem = ({
)}
{canConfigureModels &&
- (modelLoadBalancingEnabled || plan.type === Plan.sandbox) &&
+ (modelLoadBalancingEnabled || plan.type === 'sandbox') &&
!model.deprecated &&
[ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status) && (
{
beforeEach(() => {
vi.clearAllMocks()
- mockPlan(Plan.professional)
+ mockPlan('professional')
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType)
@@ -93,7 +93,7 @@ describe('WorkflowLogArchivesPage', () => {
describe('Plan access', () => {
it('should show upgrade guidance instead of archive content for sandbox workspaces', () => {
// Arrange
- mockPlan(Plan.sandbox)
+ mockPlan('sandbox')
// Act
renderPage()
@@ -105,7 +105,7 @@ describe('WorkflowLogArchivesPage', () => {
it('should open pricing modal from the sandbox upgrade guidance', () => {
// Arrange
- mockPlan(Plan.sandbox)
+ mockPlan('sandbox')
renderPage()
// Act
@@ -117,7 +117,7 @@ describe('WorkflowLogArchivesPage', () => {
it('should show archive content for paid workspaces', () => {
// Arrange
- mockPlan(Plan.professional)
+ mockPlan('professional')
// Act
renderPage()
diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
index 2aa7c661759..a2fcc882ae5 100644
--- a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
+++ b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
@@ -13,7 +13,6 @@ import { skipToken, useMutation, useQuery, useSuspenseQuery } from '@tanstack/re
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
-import { Plan } from '@/app/components/billing/type'
import { API_PREFIX } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@@ -73,7 +72,7 @@ export default function WorkflowLogArchivesPage() {
const [visibleArchiveMonthCount, setVisibleArchiveMonthCount] = useState(ARCHIVE_MONTH_PAGE_SIZE)
const loadMoreRef = useRef(null)
const canViewArchiveContent =
- deploymentEdition === 'CLOUD' && enableBilling && plan.type !== Plan.sandbox
+ deploymentEdition === 'CLOUD' && enableBilling && plan.type !== 'sandbox'
const archiveListQuery = useQuery(
consoleQuery.workflowRunArchives.get.queryOptions({
enabled: canViewArchiveContent,
diff --git a/web/app/components/header/plan-badge/__tests__/index.spec.tsx b/web/app/components/header/plan-badge/__tests__/index.spec.tsx
index a707bf34e96..f414e9c5c53 100644
--- a/web/app/components/header/plan-badge/__tests__/index.spec.tsx
+++ b/web/app/components/header/plan-badge/__tests__/index.spec.tsx
@@ -1,81 +1,20 @@
-import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
-import type { ReactElement } from 'react'
-import type { Mock } from 'vitest'
-import { fireEvent, screen } from '@testing-library/react'
-import { vi } from 'vitest'
-import { createMockProviderContextValue } from '@/__mocks__/provider-context'
-import { useProviderContext } from '@/context/provider-context'
-import { renderWithConsoleQuery } from '@/test/console/query-data'
-import { Plan } from '../../../billing/type'
+import { render, screen } from '@testing-library/react'
import { PlanBadge } from '../index'
-vi.mock('@/context/provider-context', () => ({
- useProviderContext: vi.fn(),
- baseProviderContextValue: {},
-}))
-
describe('PlanBadge', () => {
- const mockUseProviderContext = useProviderContext as Mock
- let deploymentEdition: DeploymentEdition = 'CLOUD'
- const render = (ui: ReactElement) =>
- renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: deploymentEdition } })
+ it('should render sandbox plan', () => {
+ render()
- beforeEach(() => {
- vi.clearAllMocks()
- deploymentEdition = 'CLOUD'
- })
-
- it('should return null if isFetchedPlan is false', () => {
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: false }))
- const { container } = render()
- expect(container.firstChild).toBeNull()
- })
-
- it('should render upgrade action as a button when onClick is provided', () => {
- const handleClick = vi.fn()
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
-
- render()
-
- const button = screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' })
- fireEvent.click(button)
- expect(handleClick).toHaveBeenCalledTimes(1)
- })
-
- it('should render sandbox badge instead of upgrade badge in self-hosted edition', () => {
- deploymentEdition = 'COMMUNITY'
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
-
- render()
-
- expect(screen.getByText(Plan.sandbox)).toBeInTheDocument()
- expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
- expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText('sandbox')).toBeInTheDocument()
})
it('should render professional badge when plan is professional', () => {
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
- render()
+ render()
expect(screen.getByText('pro')).toBeInTheDocument()
})
it('should render team badge when plan is team', () => {
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
- render()
- expect(screen.getByText(Plan.team)).toBeInTheDocument()
- })
-
- it('should return null when plan is enterprise', () => {
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
- const { container } = render()
- expect(container.firstChild).toBeNull()
- })
-
- it('should trigger onClick when clicked', () => {
- const handleClick = vi.fn()
- mockUseProviderContext.mockReturnValue(createMockProviderContextValue({ isFetchedPlan: true }))
- render()
- fireEvent.click(screen.getByRole('button', { name: Plan.team }))
- expect(handleClick).toHaveBeenCalledTimes(1)
+ render()
+ expect(screen.getByText('team')).toBeInTheDocument()
})
})
diff --git a/web/app/components/header/plan-badge/index.tsx b/web/app/components/header/plan-badge/index.tsx
index 34190305e07..1561c23e6b3 100644
--- a/web/app/components/header/plan-badge/index.tsx
+++ b/web/app/components/header/plan-badge/index.tsx
@@ -1,105 +1,25 @@
-import type { ReactNode } from 'react'
-import { RiGraduationCapFill } from '@remixicon/react'
-import { useSuspenseQuery } from '@tanstack/react-query'
-import { useTranslation } from 'react-i18next'
-import { useProviderContext } from '@/context/provider-context'
-import { systemFeaturesQueryOptions } from '@/features/system-features/client'
-import { SparklesSoft } from '../../base/icons/src/public/common'
-import PremiumBadge, { PremiumBadgeButton } from '../../base/premium-badge'
-import { Plan } from '../../billing/type'
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
+import PremiumBadge from '../../base/premium-badge'
-type PlanBadgeProps = {
- plan: Plan
- allowHover?: boolean
- sandboxAsUpgrade?: boolean
- onClick?: () => void
-}
-
-function PlanBadgeShell({
- size,
- color,
- allowHover,
- onClick,
- children,
-}: Pick & {
- size?: 's' | 'm'
- color: 'blue' | 'indigo' | 'gray'
- children: ReactNode
-}) {
- if (onClick) {
- return (
-
- {children}
-
- )
+export function PlanBadge({ plan }: { plan: CloudPlan }) {
+ switch (plan) {
+ case 'sandbox':
+ return (
+
+ {plan}
+
+ )
+ case 'professional':
+ return (
+
+ pro
+
+ )
+ case 'team':
+ return (
+
+ {plan}
+
+ )
}
-
- return (
-
- {children}
-
- )
-}
-
-export function PlanBadge({ plan, allowHover, sandboxAsUpgrade = false, onClick }: PlanBadgeProps) {
- const { data: deploymentEdition } = useSuspenseQuery({
- ...systemFeaturesQueryOptions(),
- select: ({ deployment_edition }) => deployment_edition,
- })
- const { isFetchedPlan, isEducationWorkspace } = useProviderContext()
- const { t } = useTranslation()
-
- if (!isFetchedPlan) return null
- if (plan === Plan.sandbox && sandboxAsUpgrade && deploymentEdition === 'CLOUD') {
- return (
-
-
-
-
- {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
-
-
-
- )
- }
- if (plan === Plan.sandbox) {
- return (
-
-
- {plan}
-
-
- )
- }
- if (plan === Plan.professional) {
- return (
-
-
-
- {isEducationWorkspace && }
- pro
-
-
-
- )
- }
- if (plan === Plan.team) {
- return (
-
-
- {plan}
-
-
- )
- }
- return null
}
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx
index c6b7c74246a..57d5f2e5fe1 100644
--- a/web/app/components/main-nav/__tests__/index.spec.tsx
+++ b/web/app/components/main-nav/__tests__/index.spec.tsx
@@ -20,7 +20,6 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
-import { Plan } from '@/app/components/billing/type'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
@@ -493,7 +492,7 @@ const consoleState: MainNavConsoleState = {
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.team,
+ plan: 'team',
credits: 7500,
role: 'owner',
},
@@ -602,7 +601,7 @@ describe('MainNav', () => {
{
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.team,
+ plan: 'team',
status: 'normal',
created_at: 0,
current: true,
@@ -610,7 +609,7 @@ describe('MainNav', () => {
{
id: 'workspace-2',
name: 'Evan Workspace',
- plan: Plan.sandbox,
+ plan: 'sandbox',
status: 'normal',
created_at: 0,
current: false,
@@ -632,9 +631,8 @@ describe('MainNav', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
enableEducationPlan: false,
- isEducationWorkspace: false,
isFetchedPlan: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
} as ProviderContextState)
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
@@ -667,9 +665,9 @@ describe('MainNav', () => {
it('renders primary navigation with the planned routes', () => {
renderMainNav()
- expect(screen.getAllByText(Plan.team)).toHaveLength(1)
+ expect(screen.getAllByText('team')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent(
- Plan.team,
+ 'team',
)
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
@@ -767,9 +765,8 @@ describe('MainNav', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
enableEducationPlan: true,
- isEducationWorkspace: false,
isFetchedPlan: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
} as ProviderContextState)
renderMainNav(defaultMainNavSystemFeatures, {
@@ -780,7 +777,7 @@ describe('MainNav', () => {
expect(await screen.findByText('EDU')).toBeInTheDocument()
expect(screen.getByText('evan@example.com')).toBeInTheDocument()
- expect(screen.getAllByText(Plan.team)).toHaveLength(1)
+ expect(screen.getAllByText('team')).toHaveLength(1)
})
it('keeps unrestricted main routes visible for dataset operators while hiding roster', () => {
@@ -1248,7 +1245,7 @@ describe('MainNav', () => {
...consoleState,
currentWorkspace: {
...consoleState.currentWorkspace,
- plan: Plan.sandbox,
+ plan: 'sandbox',
},
}
@@ -1263,7 +1260,7 @@ describe('MainNav', () => {
...consoleState,
currentWorkspace: {
...consoleState.currentWorkspace,
- plan: Plan.professional,
+ plan: 'professional',
},
}
diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx
index a2bf6fbf589..2deecf75867 100644
--- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx
+++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx
@@ -6,7 +6,6 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { fireEvent, screen } from '@testing-library/react'
import { openZendeskWindow } from '@/app/components/base/zendesk/utils'
-import { Plan } from '@/app/components/billing/type'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@@ -74,7 +73,7 @@ describe('SupportMenu', () => {
}
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
- plan: { type: Plan.team },
+ plan: { type: 'team' },
})
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
@@ -125,7 +124,7 @@ describe('SupportMenu', () => {
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
})
renderSupportMenu()
@@ -153,7 +152,7 @@ describe('SupportMenu', () => {
it('hides upgrade contact for Cloud sandbox plan when billing is disabled', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: false,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
})
renderSupportMenu()
@@ -168,7 +167,7 @@ describe('SupportMenu', () => {
mockConfig.supportEmailAddress = 'support@example.com'
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
})
renderSupportMenu()
@@ -186,7 +185,7 @@ describe('SupportMenu', () => {
mockConfig.zendeskWidgetKey = ''
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
})
renderSupportMenu()
@@ -196,7 +195,7 @@ describe('SupportMenu', () => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
expect(mailToSupport).toHaveBeenCalledWith(
'user@example.com',
- Plan.sandbox,
+ 'sandbox',
'1.0.0',
'support@example.com',
)
@@ -206,7 +205,7 @@ describe('SupportMenu', () => {
deploymentEdition = 'COMMUNITY'
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
})
renderSupportMenu()
@@ -223,7 +222,7 @@ describe('SupportMenu', () => {
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
- expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0', '')
+ expect(mailToSupport).toHaveBeenCalledWith('user@example.com', 'team', '1.0.0', '')
expect(
screen.getByRole('menuitem', { name: 'common.userProfile.emailSupport' }),
).toHaveAttribute('href', 'mailto:support@example.com')
diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx
index 132c289bd84..ecf67d387f2 100644
--- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx
+++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx
@@ -7,7 +7,6 @@ import type { ProviderContextState } from '@/context/provider-context'
import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@@ -99,7 +98,7 @@ vi.mock('@/service/client', async (importOriginal) => {
const currentWorkspaceValue: GetWorkspacesCurrentSummaryResponse = {
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.sandbox,
+ plan: 'sandbox',
role: 'owner',
credits: 7500,
}
@@ -157,7 +156,7 @@ describe('WorkspaceCard', () => {
{
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.sandbox,
+ plan: 'sandbox',
status: 'normal',
created_at: 0,
current: true,
@@ -165,7 +164,7 @@ describe('WorkspaceCard', () => {
{
id: 'workspace-2',
name: 'Evan Workspace',
- plan: Plan.team,
+ plan: 'team',
status: 'normal',
created_at: 0,
current: false,
@@ -177,9 +176,8 @@ describe('WorkspaceCard', () => {
vi.mocked(useProviderContext).mockReturnValue({
enableBilling: true,
enableEducationPlan: false,
- isEducationWorkspace: false,
isFetchedPlan: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
} as ProviderContextState)
mockWorkspacePermissionKeys(['workspace.member.manage'])
vi.mocked(useModalContext).mockReturnValue({
@@ -300,37 +298,36 @@ describe('WorkspaceCard', () => {
it('uses the current workspace query for billing plan UI', () => {
mockCurrentWorkspaceQuery({
...currentWorkspaceValue,
- plan: Plan.team,
+ plan: 'team',
})
vi.mocked(useProviderContext).mockReturnValue({
enableBilling: false,
enableEducationPlan: false,
- isEducationWorkspace: false,
isFetchedPlan: true,
- plan: { type: Plan.sandbox },
+ plan: { type: 'sandbox' },
} as ProviderContextState)
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
- expect(screen.getByText(Plan.team)).toBeInTheDocument()
+ expect(screen.getByText('team')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.plain')).toBeInTheDocument()
- expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument()
+ expect(screen.queryByText('sandbox')).not.toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
})
it('uses the original paid plan badge for paid workspaces', () => {
mockCurrentWorkspaceQuery({
...currentWorkspaceValue,
- plan: Plan.team,
+ plan: 'team',
})
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
- expect(screen.getByText(Plan.team)).toBeInTheDocument()
+ expect(screen.getByText('team')).toBeInTheDocument()
})
it('shows the Enterprise license status independently of the Cloud billing state', () => {
mockCurrentWorkspaceQuery({
...currentWorkspaceValue,
- plan: '',
+ plan: null,
})
renderWorkspaceCard({
systemFeatures: {
@@ -342,7 +339,7 @@ describe('WorkspaceCard', () => {
})
expect(screen.getByText('Enterprise')).toBeInTheDocument()
- expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument()
+ expect(screen.queryByText('sandbox')).not.toBeInTheDocument()
})
it('opens workspace actions and switcher in a popover panel', async () => {
@@ -406,7 +403,7 @@ describe('WorkspaceCard', () => {
{
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.sandbox,
+ plan: 'sandbox',
status: 'normal',
created_at: 1,
last_opened_at: 20,
@@ -415,7 +412,7 @@ describe('WorkspaceCard', () => {
{
id: 'workspace-2',
name: 'Evan Workspace',
- plan: Plan.team,
+ plan: 'team',
status: 'normal',
created_at: 3,
last_opened_at: null,
@@ -424,7 +421,7 @@ describe('WorkspaceCard', () => {
{
id: 'workspace-3',
name: 'Atlas Workspace',
- plan: Plan.team,
+ plan: 'team',
status: 'normal',
created_at: 2,
last_opened_at: 30,
diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx
index d4f8e7c87ec..49f92d8ffa0 100644
--- a/web/app/components/main-nav/components/support-menu.tsx
+++ b/web/app/components/main-nav/components/support-menu.tsx
@@ -2,7 +2,6 @@ import { DropdownMenuItem, DropdownMenuLinkItem } from '@langgenius/dify-ui/drop
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { openZendeskWindow } from '@/app/components/base/zendesk/utils'
-import { Plan } from '@/app/components/billing/type'
import {
ExternalLinkIndicator,
MenuItemContent,
@@ -29,11 +28,11 @@ export default function SupportMenu() {
}),
})
const { setShowPricingModal } = useModalContext()
- const hasDedicatedChannel = plan.type !== Plan.sandbox || Boolean(SUPPORT_EMAIL_ADDRESS.trim())
+ const hasDedicatedChannel = plan.type !== 'sandbox' || Boolean(SUPPORT_EMAIL_ADDRESS.trim())
const shouldShowUpgradeContact =
deploymentEdition === 'CLOUD' &&
enableBilling &&
- plan.type === Plan.sandbox &&
+ plan.type === 'sandbox' &&
!hasDedicatedChannel
const hasZendeskWidget = deploymentEdition === 'CLOUD' && Boolean(ZENDESK_WIDGET_KEY.trim())
diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx
index 9c43f0621d6..981e629537b 100644
--- a/web/app/components/main-nav/components/workspace-card.tsx
+++ b/web/app/components/main-nav/components/workspace-card.tsx
@@ -11,7 +11,6 @@ import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
-import { Plan } from '@/app/components/billing/type'
import {
settingsQueryParamName,
settingsQueryParser,
@@ -34,12 +33,6 @@ const workspaceMenuTriggerHeight = 36
const workspaceMenuAlignOffset = -28
const workspaceCardSkeletonClassName =
'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
-const workspacePlans = new Set(Object.values(Plan))
-
-function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
- return !!plan && workspacePlans.has(plan)
-}
-
function WorkspaceCardSkeleton({
showCloudBilling,
showPlanAction,
@@ -288,11 +281,11 @@ export function WorkspaceCard() {
)
}
- const workspacePlan = isWorkspacePlan(currentWorkspace.plan) ? currentWorkspace.plan : null
- const hasBillingPlan = typeof currentWorkspace.plan === 'string'
+ const workspacePlan = currentWorkspace.plan
+ const hasBillingPlan = workspacePlan !== null
const showCloudBilling = isCloudEdition && hasBillingPlan
- const showPlanAction = showCloudBilling && workspacePlan !== null
- const isFreePlan = workspacePlan === Plan.sandbox
+ const showPlanAction = showCloudBilling
+ const isFreePlan = workspacePlan === 'sandbox'
const planActionLabel = t(
($) => $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'],
{ ns: 'billing' },
diff --git a/web/app/components/main-nav/components/workspace-plan-badge.tsx b/web/app/components/main-nav/components/workspace-plan-badge.tsx
index 1ee7aee7b66..b65995831e7 100644
--- a/web/app/components/main-nav/components/workspace-plan-badge.tsx
+++ b/web/app/components/main-nav/components/workspace-plan-badge.tsx
@@ -1,13 +1,13 @@
+import type { CloudPlan } from '@dify/contracts/api/console/workspaces/types.gen'
import Badge from '@/app/components/base/badge'
-import { Plan } from '@/app/components/billing/type'
import { PlanBadge } from '@/app/components/header/plan-badge'
type WorkspacePlanBadgeProps = {
- plan: Plan
+ plan: CloudPlan
}
const WorkspacePlanBadge = ({ plan }: WorkspacePlanBadgeProps) => {
- if (plan !== Plan.sandbox) return
+ if (plan !== 'sandbox') return
return (
diff --git a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
index 9fce1300b3d..591586b674f 100644
--- a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
+++ b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
@@ -10,7 +10,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
-import { Plan } from '@/app/components/billing/type'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
@@ -401,7 +400,7 @@ function getMockAppContextState() {
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
- plan: Plan.sandbox,
+ plan: 'sandbox',
role: mockCurrentWorkspaceRole.value,
},
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager.value,
diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx
index 812e3712a4a..4f473499178 100644
--- a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx
+++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx
@@ -3,7 +3,6 @@ import type { ProviderContextState } from '@/context/provider-context'
import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { Plan } from '@/app/components/billing/type'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { parseParamsSchema } from '@/service/tools'
import EditCustomCollectionModal from '../index'
@@ -65,7 +64,7 @@ describe('EditCustomCollectionModal', () => {
})
mockUseProviderContext.mockReturnValue({
plan: {
- type: Plan.sandbox,
+ type: 'sandbox',
},
enableBilling: false,
webappCopyrightEnabled: true,
diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
index 1a99810e9b7..cafae7b2526 100644
--- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
@@ -1,3 +1,4 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { ReactElement } from 'react'
import type { AppPublisherProps } from '@/app/components/app/app-publisher/types'
import type { App } from '@/types/app'
@@ -5,7 +6,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
-import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/client'
import FeaturesTrigger from '../features-trigger'
@@ -236,10 +236,10 @@ vi.mock('@/hooks/use-theme', () => ({
// Use real app store - global zustand mock will auto-reset between tests
const createProviderContext = ({
- type = Plan.sandbox,
+ type = 'sandbox',
isFetchedPlan = true,
}: {
- type?: Plan
+ type?: CloudPlan
isFetchedPlan?: boolean
}) => ({
plan: { type },
diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
index 4e10ef912e6..e9947de4bdb 100644
--- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
@@ -12,7 +12,6 @@ import { useEdges } from 'reactflow'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeatures } from '@/app/components/base/features/hooks'
-import { Plan } from '@/app/components/billing/type'
// useWorkflowRunValidation,
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import {
@@ -118,7 +117,7 @@ const FeaturesTrigger = () => {
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) return count + 1
return count
}, 0)
- return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
+ return isFetchedPlan && plan.type === 'sandbox' && entryCount > 2
}, [nodes, plan.type, isFetchedPlan])
const hasHumanInputNode = useMemo(() => {
diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx
index 20eb4fbcfde..2f225ebc99b 100644
--- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx
@@ -1,6 +1,6 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
-import { Plan } from '@/app/components/billing/type'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
@@ -11,7 +11,7 @@ const mockInvalidAllLastRun = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
-let mockPlanType = Plan.professional
+let mockPlanType: CloudPlan = 'professional'
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
@@ -88,7 +88,7 @@ const createVersion = (overrides: Partial = {}): VersionHistory
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockPlanType = Plan.professional
+ mockPlanType = 'professional'
mockEnableBilling = true
})
@@ -142,7 +142,7 @@ describe('HeaderInRestoring', () => {
})
it('should show plan upgrade modal instead of restoring when sandbox users click restore', () => {
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
renderWorkflowComponent(, {
initialStoreState: {
currentVersion: createVersion(),
diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx
index 1c8584f8824..20a75844dc7 100644
--- a/web/app/components/workflow/header/header-in-restoring.tsx
+++ b/web/app/components/workflow/header/header-in-restoring.tsx
@@ -6,7 +6,6 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
-import { Plan } from '@/app/components/billing/type'
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@@ -49,7 +48,7 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const canRestore =
!!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
- const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox
+ const canUseWorkflowVersionAction = !enableBilling || plan.type !== 'sandbox'
const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow
const handleCancelRestore = useCallback(() => {
diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx
index 2d358714fe7..607b8cdd7fa 100644
--- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx
@@ -1,9 +1,9 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect, useRef } from 'react'
-import { Plan } from '@/app/components/billing/type'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
@@ -25,7 +25,7 @@ const mockToast = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
-let mockPlanType = Plan.professional
+let mockPlanType: CloudPlan = 'professional'
let mockEnableBilling = true
let mockPublishedEnvironments: VersionHistory['environments']
let mockHasNextPage = false
@@ -284,7 +284,7 @@ describe('VersionHistoryPanel', () => {
mockRestoreWorkflow.mockResolvedValue(undefined)
mockUpdateWorkflow.mockResolvedValue(undefined)
mockCurrentVersion = null
- mockPlanType = Plan.professional
+ mockPlanType = 'professional'
mockEnableBilling = true
mockPublishedEnvironments = undefined
mockHasNextPage = false
@@ -387,7 +387,7 @@ describe('VersionHistoryPanel', () => {
it('should show plan upgrade modal instead of restore confirmation for sandbox users', async () => {
const { VersionHistoryPanel } = await import('../index')
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
render(
{
it('should show plan upgrade modal instead of exporting DSL for sandbox users', async () => {
const { VersionHistoryPanel } = await import('../index')
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
render(
{
}
})
-let mockPlanType = Plan.professional
+let mockPlanType: CloudPlan = 'professional'
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
@@ -32,7 +32,7 @@ vi.mock('@/context/provider-context', () => ({
describe('ActionMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockPlanType = Plan.professional
+ mockPlanType = 'professional'
mockEnableBilling = true
})
@@ -74,7 +74,7 @@ describe('ActionMenu', () => {
it('shows upgrade buttons beside restore and export for sandbox users', async () => {
const user = userEvent.setup()
const handleClickActionMenuItem = vi.fn()
- mockPlanType = Plan.sandbox
+ mockPlanType = 'sandbox'
renderActionMenu(
{
const { t } = useTranslation()
const pipelineId = useStore((s) => s.pipelineId)
const { plan, enableBilling } = useProviderContext()
- const shouldShowUpgrade = enableBilling && plan.type === Plan.sandbox
+ const shouldShowUpgrade = enableBilling && plan.type === 'sandbox'
const deleteOperation = {
key: VersionHistoryContextMenuOptions.delete,
diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx
index 077244d0cd2..c8b4d7872f1 100644
--- a/web/app/components/workflow/panel/version-history-panel/index.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/index.tsx
@@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next'
import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal'
import Divider from '@/app/components/base/divider'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
-import { Plan } from '@/app/components/billing/type'
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@@ -67,7 +66,7 @@ export const VersionHistoryPanel = ({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const { plan, enableBilling } = useProviderContext()
- const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox
+ const canUseWorkflowVersionAction = !enableBilling || plan.type !== 'sandbox'
const workflowStore = useWorkflowStore()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
diff --git a/web/app/education/apply/__tests__/page.spec.tsx b/web/app/education/apply/__tests__/page.spec.tsx
index b690b129d7a..f231a09aa8e 100644
--- a/web/app/education/apply/__tests__/page.spec.tsx
+++ b/web/app/education/apply/__tests__/page.spec.tsx
@@ -2,13 +2,12 @@ import type { GetAccountProfileResponse } from '@dify/contracts/api/console/acco
import { toast } from '@langgenius/dify-ui/toast'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { Plan } from '@/app/components/billing/type'
import EducationApplyPage from '@/app/education/apply/application-form'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
let mockConsoleState: Record = {}
-const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
+const mockGetSubscription = vi.hoisted(() => vi.fn())
const mockEducationAdd = vi.hoisted(() => vi.fn())
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockWorkspaces = vi.hoisted(() => [
@@ -39,10 +38,6 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
-vi.mock('@/service/billing', () => ({
- fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
-}))
-
vi.mock('@/service/use-common', () => ({
useLogout: () => ({ mutateAsync: vi.fn() }),
}))
@@ -57,6 +52,7 @@ vi.mock('@/service/client', () => ({
invoices: {
get: vi.fn().mockResolvedValue({ url: 'https://billing.example.com' }),
},
+ subscription: { get: mockGetSubscription },
},
},
consoleQuery: {
@@ -136,7 +132,7 @@ const renderPage = (isEducationAccount = true) => {
educationStatus: { is_student: isEducationAccount },
workspacePermissionKeys: null,
})
- return render(, {
+ return render(, {
wrapper,
})
}
@@ -146,7 +142,7 @@ describe('EducationApplyPage billing boundary', () => {
vi.clearAllMocks()
cleanup()
vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id')
- mockFetchSubscriptionUrls.mockResolvedValue({ url: window.location.href })
+ mockGetSubscription.mockResolvedValue({ url: window.location.href })
mockSwitchWorkspace.mockResolvedValue(undefined)
vi.stubGlobal('location', {
href: 'https://console.example.com/education/apply?token=education-token',
@@ -167,7 +163,9 @@ describe('EducationApplyPage billing boundary', () => {
await user.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
- expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
+ expect(mockGetSubscription).toHaveBeenCalledWith({
+ query: { plan: 'professional', interval: 'year' },
+ })
})
})
diff --git a/web/app/education/apply/application-form.tsx b/web/app/education/apply/application-form.tsx
index 3a14aadd1a6..e13b62edc7e 100644
--- a/web/app/education/apply/application-form.tsx
+++ b/web/app/education/apply/application-form.tsx
@@ -16,7 +16,6 @@ import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
-import { Plan } from '@/app/components/billing/type'
import { useDocLink } from '@/context/i18n'
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
@@ -66,7 +65,7 @@ const EducationApplyPage = ({ plan, token }: EducationApplyPageProps) => {
const appliedEducationCase = (() => {
if (!isCurrentWorkspaceManager) return AppliedEducationCase.noPaymentPermission
- if (plan === Plan.sandbox) return AppliedEducationCase.eligible
+ if (plan === 'sandbox') return AppliedEducationCase.eligible
return AppliedEducationCase.activeSubscription
})()
diff --git a/web/app/education/apply/applied-education-content.tsx b/web/app/education/apply/applied-education-content.tsx
index fc32df65138..5b16c8f4462 100644
--- a/web/app/education/apply/applied-education-content.tsx
+++ b/web/app/education/apply/applied-education-content.tsx
@@ -8,7 +8,6 @@ import type {
import type { ReactNode } from 'react'
import { Select, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
-import { Plan } from '@/app/components/billing/type'
import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector'
import { PlanBadge } from '@/app/components/header/plan-badge'
@@ -21,12 +20,6 @@ type AppliedEducationContentProps = {
onSwitchWorkspace: (tenantId: string) => void
}
-const workspacePlans = new Set(Object.values(Plan))
-
-function isWorkspacePlan(plan: string | null | undefined): plan is Plan {
- return !!plan && workspacePlans.has(plan)
-}
-
const AppliedEducationContent = ({
workspaces,
currentWorkspace,
@@ -37,11 +30,7 @@ const AppliedEducationContent = ({
}: AppliedEducationContentProps) => {
const { t } = useTranslation()
const currentWorkspaceInList = workspaces.find((workspace) => workspace.current)
- const workspacePlan = isWorkspacePlan(currentWorkspaceInList?.plan)
- ? currentWorkspaceInList.plan
- : isWorkspacePlan(plan)
- ? plan
- : Plan.sandbox
+ const workspacePlan = currentWorkspaceInList?.plan ?? plan
const workspaceName = currentWorkspaceInList?.name || currentWorkspace?.name
const workspaceId = currentWorkspaceInList?.id || currentWorkspace?.id
diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts
index 2cb12460036..8b017697983 100644
--- a/web/context/hooks/use-trigger-events-limit-modal.ts
+++ b/web/context/hooks/use-trigger-events-limit-modal.ts
@@ -1,8 +1,8 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NUM_INFINITE } from '@/app/components/billing/config'
-import { Plan } from '@/app/components/billing/type'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { isServer } from '@/utils/client'
@@ -18,7 +18,7 @@ type TriggerEventsLimitModalState = TriggerEventsLimitModalContent & {
}
type TriggerPlanInfo = {
- type: Plan
+ type: CloudPlan
usage: { triggerEvents: number }
total: { triggerEvents: number }
reset: { triggerEvents?: number | null }
@@ -63,19 +63,19 @@ export const useTriggerEventsLimitModal = ({
const isUnlimited = total.triggerEvents === NUM_INFINITE
const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents
- if (type === Plan.team || isUnlimited || !reachedLimit) {
+ if (type === 'team' || isUnlimited || !reachedLimit) {
if (triggerEventsLimitModal) setTriggerEventsLimitModal(null)
return
}
const triggerResetInDays =
- type === Plan.professional && total.triggerEvents !== NUM_INFINITE
+ type === 'professional' && total.triggerEvents !== NUM_INFINITE
? (reset.triggerEvents ?? undefined)
: undefined
const cycleTag = (() => {
if (typeof reset.triggerEvents === 'number')
return dayjs().startOf('day').add(reset.triggerEvents, 'day').format('YYYY-MM-DD')
- if (type === Plan.sandbox) return dayjs().endOf('month').format('YYYY-MM-DD')
+ if (type === 'sandbox') return dayjs().endOf('month').format('YYYY-MM-DD')
return 'none'
})()
const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}`
diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx
index 5fedde87cac..596393fc391 100644
--- a/web/context/modal-context.test.tsx
+++ b/web/context/modal-context.test.tsx
@@ -1,8 +1,8 @@
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
-import { Plan } from '@/app/components/billing/type'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { useModalContextSelector } from '@/context/modal-context'
import { ModalContextProvider } from '@/context/modal-context-provider'
@@ -46,8 +46,12 @@ type ResetShape = {
apiRateLimit: number | null
triggerEvents: number | null
}
-type PlanShape = Omit & { reset: ResetShape }
-type PlanOverrides = Partial> & {
+type PlanShape = Omit & {
+ type: CloudPlan
+ reset: ResetShape
+}
+type PlanOverrides = Partial> & {
+ type?: CloudPlan
usage?: Partial
total?: Partial
reset?: Partial
@@ -145,7 +149,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
const plan = createPlan({
- type: Plan.professional,
+ type: 'professional',
usage: { triggerEvents: 3000 },
total: { triggerEvents: 3000 },
reset: { triggerEvents: 5 },
@@ -179,7 +183,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
it('relies on the in-memory guard when localStorage reads throw', async () => {
const plan = createPlan({
- type: Plan.professional,
+ type: 'professional',
usage: { triggerEvents: 200 },
total: { triggerEvents: 200 },
reset: { triggerEvents: 3 },
@@ -213,7 +217,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
const plan = createPlan({
- type: Plan.professional,
+ type: 'professional',
usage: { triggerEvents: 120 },
total: { triggerEvents: 120 },
reset: { triggerEvents: 2 },
@@ -245,7 +249,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
it('closes the trigger events limit modal and opens pricing when upgrading', async () => {
const plan = createPlan({
- type: Plan.professional,
+ type: 'professional',
usage: { triggerEvents: 400 },
total: { triggerEvents: 400 },
reset: { triggerEvents: 6 },
diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx
index 0d8db65969c..67b60e7f461 100644
--- a/web/context/provider-context-provider.tsx
+++ b/web/context/provider-context-provider.tsx
@@ -1,10 +1,9 @@
'use client'
import type { ReactNode } from 'react'
-import type { ProviderContextState } from './provider-context'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { defaultPlan } from '@/app/components/billing/config'
import { parseCurrentPlan } from '@/app/components/billing/utils'
@@ -14,7 +13,6 @@ import {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ZENDESK_FIELD_IDS } from '@/config'
import { deploymentEditionAtom } from '@/features/system-features/state'
-import { fetchCurrentPlanInfo } from '@/service/billing'
import { consoleQuery } from '@/service/client'
import {
commonQueryKeys,
@@ -27,41 +25,10 @@ type ProviderContextProviderProps = {
children: ReactNode
}
-type MemberInviteLimit = {
- size: number
- limit: number
-}
-
-const unlimitedMemberInviteLimit: MemberInviteLimit = {
- size: 0,
- limit: 0,
-}
-
-const resolveMemberInviteLimit = (
- data: Awaited>,
-): MemberInviteLimit => {
- if (!data) return unlimitedMemberInviteLimit
-
- if (data.workspace_members?.enabled) {
- return {
- size: data.workspace_members.size,
- limit: data.workspace_members.limit,
- }
- }
-
- if (data.billing?.enabled && data.members?.limit > 0) {
- return {
- size: data.members.size,
- limit: data.members.limit,
- }
- }
-
- return unlimitedMemberInviteLimit
-}
-
export const ProviderContextProvider = ({ children }: ProviderContextProviderProps) => {
const deploymentEdition = useAtomValue(deploymentEditionAtom)
const queryClient = useQueryClient()
+ const featuresQuery = useQuery(consoleQuery.features.get.queryOptions())
const {
data: providersData,
isLoading: isLoadingModelProviders,
@@ -70,29 +37,19 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
const { data: textGenerationModelList } = useModelListByType(ModelTypeEnum.textGeneration)
const { data: supportRetrievalMethods } = useSupportRetrievalMethods()
- const [plan, setPlan] = useState(defaultPlan)
- const [isFetchedPlan, setIsFetchedPlan] = useState(false)
- const [isFetchedPlanInfo, setIsFetchedPlanInfo] = useState(false)
- const [enableBilling, setEnableBilling] = useState(true)
- const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false)
- const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
- const [datasetOperatorEnabled, setDatasetOperatorEnabled] = useState(false)
- const [webappCopyrightEnabled, setWebappCopyrightEnabled] = useState(false)
- const [licenseLimit, setLicenseLimit] = useState({
- workspace_members: {
- size: 0,
- limit: 0,
- },
- })
-
- const [enableEducationPlan, setEnableEducationPlan] = useState(false)
- const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
- const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
- const [
- isAllowPublishAsCustomKnowledgePipelineTemplate,
- setIsAllowPublishAsCustomKnowledgePipelineTemplate,
- ] = useState(false)
- const [humanInputEmailDeliveryEnabled, setHumanInputEmailDeliveryEnabled] = useState(false)
+ const features = featuresQuery.data
+ const enableBilling = features?.billing.enabled ?? false
+ const plan = enableBilling && features ? parseCurrentPlan(features) : defaultPlan
+ const isFetchedPlan = featuresQuery.isSuccess && enableBilling
+ const isFetchedPlanInfo = featuresQuery.isFetched
+ const enableEducationPlan = features?.education.enabled ?? false
+ const enableReplaceWebAppLogo = features?.can_replace_logo ?? false
+ const modelLoadBalancingEnabled = features?.model_load_balancing_enabled ?? false
+ const webappCopyrightEnabled = features?.webapp_copyright_enabled ?? false
+ const isAllowTransferWorkspace = features?.is_allow_transfer_workspace ?? false
+ const isAllowPublishAsCustomKnowledgePipelineTemplate =
+ features?.knowledge_pipeline.publish_enabled ?? false
+ const humanInputEmailDeliveryEnabled = features?.human_input_email_delivery_enabled ?? false
const refreshModelProviders = () =>
Promise.all([
@@ -102,49 +59,10 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
]).then(() => undefined)
- const fetchPlan = async () => {
- try {
- const data = await fetchCurrentPlanInfo()
- if (!data) {
- console.error('Failed to fetch plan info: data is undefined')
- return
- }
-
- // set default value to avoid undefined error
- setEnableBilling(data.billing?.enabled ?? false)
- setEnableEducationPlan(data.education?.enabled ?? false)
- setIsEducationWorkspace(data.education?.activated ?? false)
- setEnableReplaceWebAppLogo(data.can_replace_logo ?? false)
-
- if (data.billing?.enabled) {
- setPlan(parseCurrentPlan(data))
- setIsFetchedPlan(true)
- }
-
- if (data.model_load_balancing_enabled) setModelLoadBalancingEnabled(true)
- if (data.dataset_operator_enabled) setDatasetOperatorEnabled(true)
- if (data.webapp_copyright_enabled) setWebappCopyrightEnabled(true)
- setLicenseLimit({ workspace_members: resolveMemberInviteLimit(data) })
- if (data.is_allow_transfer_workspace)
- setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
- if (data.knowledge_pipeline?.publish_enabled)
- setIsAllowPublishAsCustomKnowledgePipelineTemplate(data.knowledge_pipeline?.publish_enabled)
- if (data.human_input_email_delivery_enabled)
- setHumanInputEmailDeliveryEnabled(data.human_input_email_delivery_enabled)
- } catch (error) {
- console.error('Failed to fetch plan info:', error)
- // set default value to avoid undefined error
- setEnableBilling(false)
- setEnableEducationPlan(false)
- setIsEducationWorkspace(false)
- setEnableReplaceWebAppLogo(false)
- } finally {
- setIsFetchedPlanInfo(true)
- }
- }
- useEffect(() => {
- fetchPlan()
- }, [])
+ const refreshFeatures = () =>
+ queryClient
+ .invalidateQueries({ queryKey: consoleQuery.features.get.key() })
+ .then(() => undefined)
// #region Zendesk conversation fields
useEffect(() => {
@@ -179,15 +97,11 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
isFetchedPlan,
isFetchedPlanInfo,
enableBilling,
- onPlanInfoChanged: fetchPlan,
+ onPlanInfoChanged: refreshFeatures,
enableReplaceWebAppLogo,
modelLoadBalancingEnabled,
- datasetOperatorEnabled,
enableEducationPlan,
- isEducationWorkspace,
webappCopyrightEnabled,
- licenseLimit,
- refreshLicenseLimit: fetchPlan,
isAllowTransferWorkspace,
isAllowPublishAsCustomKnowledgePipelineTemplate,
humanInputEmailDeliveryEnabled,
diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts
index 73a69d20ae6..3ed84b46e93 100644
--- a/web/context/provider-context.ts
+++ b/web/context/provider-context.ts
@@ -1,10 +1,11 @@
'use client'
+import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type {
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
-import type { Plan, UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
+import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RETRIEVE_METHOD } from '@/types/app'
import { noop } from 'es-toolkit/function'
@@ -21,7 +22,7 @@ export type ProviderContextState = {
supportRetrievalMethods: RETRIEVE_METHOD[]
isAPIKeySet: boolean
plan: {
- type: Plan
+ type: CloudPlan
usage: UsagePlanInfo
total: UsagePlanInfo
reset: UsageResetInfo
@@ -32,17 +33,8 @@ export type ProviderContextState = {
onPlanInfoChanged: () => void
enableReplaceWebAppLogo: boolean
modelLoadBalancingEnabled: boolean
- datasetOperatorEnabled: boolean
enableEducationPlan: boolean
- isEducationWorkspace: boolean
webappCopyrightEnabled: boolean
- licenseLimit: {
- workspace_members: {
- size: number
- limit: number
- }
- }
- refreshLicenseLimit: () => void
isAllowTransferWorkspace: boolean
isAllowPublishAsCustomKnowledgePipelineTemplate: boolean
humanInputEmailDeliveryEnabled: boolean
@@ -64,17 +56,8 @@ export const baseProviderContextValue: ProviderContextState = {
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
- datasetOperatorEnabled: false,
enableEducationPlan: false,
- isEducationWorkspace: false,
webappCopyrightEnabled: false,
- licenseLimit: {
- workspace_members: {
- size: 0,
- limit: 0,
- },
- },
- refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
diff --git a/web/service/billing.ts b/web/service/billing.ts
deleted file mode 100644
index 075ab71adeb..00000000000
--- a/web/service/billing.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { CurrentPlanInfoBackend, SubscriptionUrlsBackend } from '@/app/components/billing/type'
-import { get } from './base'
-
-export const fetchCurrentPlanInfo = () => {
- return get('/features')
-}
-
-export const fetchSubscriptionUrls = (plan: string, interval: string) => {
- return get(`/billing/subscription?plan=${plan}&interval=${interval}`)
-}