diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py
index e4352f92f88..638af52e284 100644
--- a/api/controllers/console/error.py
+++ b/api/controllers/console/error.py
@@ -109,6 +109,12 @@ class EducationActivateLimitError(BaseHTTPException):
code = 429
+class EducationDiscountTemporarilyPausedError(BaseHTTPException):
+ error_code = "education_discount_temporarily_paused"
+ description = "Education discount temporarily paused, while we upgrade our security measures."
+ code = 503
+
+
class ComplianceRateLimitError(BaseHTTPException):
error_code = "compliance_rate_limit"
description = "Rate limit exceeded for downloading compliance report."
diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py
index 490d2e3a1e1..a1fcbf976a2 100644
--- a/api/controllers/console/workspace/account.py
+++ b/api/controllers/console/workspace/account.py
@@ -28,7 +28,12 @@ from controllers.console.auth.error import (
InvalidEmailError,
InvalidTokenError,
)
-from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
+from controllers.console.error import (
+ AccountInFreezeError,
+ AccountNotFound,
+ EducationDiscountTemporarilyPausedError,
+ EmailSendIpLimitError,
+)
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
@@ -557,11 +562,7 @@ class EducationApi(Resource):
@cloud_edition_billing_enabled
@with_current_user
def post(self, account: Account):
- payload = console_ns.payload or {}
- args = EducationActivatePayload.model_validate(payload)
-
- result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
- return result
+ raise EducationDiscountTemporarilyPausedError()
@setup_required
@login_required
diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py
index 40a3f06ad29..ce10cc9e070 100644
--- a/api/tests/unit_tests/controllers/console/test_workspace_account.py
+++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py
@@ -8,12 +8,14 @@ import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
+from controllers.console.error import EducationDiscountTemporarilyPausedError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
ChangeEmailResetApi,
ChangeEmailSendEmailApi,
CheckEmailUnique,
+ EducationApi,
)
from models import Account, AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
@@ -106,6 +108,27 @@ def _build_change_email_token(
raise AssertionError(f"Unsupported phase for test helper: {phase}")
+class TestEducationApi:
+ @patch("controllers.console.workspace.account.BillingService.EducationIdentity.activate")
+ def test_post_returns_temporarily_paused_error_without_activating_discount(
+ self, mock_activate: MagicMock, app: Flask
+ ):
+ account = _build_account("student@example.edu")
+
+ with app.test_request_context("/account/education", method="POST", json={}):
+ api = EducationApi()
+ method = inspect.unwrap(api.post)
+ with pytest.raises(EducationDiscountTemporarilyPausedError) as exc_info:
+ method(api, account)
+
+ assert exc_info.value.data == {
+ "code": "education_discount_temporarily_paused",
+ "message": "Education discount temporarily paused, while we upgrade our security measures.",
+ "status": 503,
+ }
+ mock_activate.assert_not_called()
+
+
class TestChangeEmailSend:
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx
index 01c1ef9a488..8d578b9c5c9 100644
--- a/web/__tests__/billing/education-verification-flow.test.tsx
+++ b/web/__tests__/billing/education-verification-flow.test.tsx
@@ -4,8 +4,7 @@ import type { ReactElement } from 'react'
* Integration test: Education Verification Flow
*
* Tests the education plan verification flow in PlanComp:
- * PlanComp → handleVerify → useEducationVerify → router.push → education-apply
- * PlanComp → handleVerify → error → show VerifyStateModal
+ * PlanComp → handleVerify → show temporary pause notice
*
* Also covers education button visibility based on context flags.
*/
@@ -102,14 +101,16 @@ vi.mock('@/app/education-apply/verify-state-modal', () => ({
}: {
isShow: boolean
title?: string
- content?: string
+ content?: React.ReactNode
email?: string
showLink?: boolean
}) =>
isShow ? (
{title && {title}}
- {content && {content}}
+ {content !== undefined && content !== null ? (
+ {content}
+ ) : null}
{email && {email}}
{showLink && link}
@@ -208,67 +209,9 @@ describe('Education Verification Flow', () => {
})
})
- // ─── 2. Successful Verification Flow ────────────────────────────────────
- describe('Successful verification flow', () => {
- it('should let non-manager members start education verification', async () => {
- mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
- setupContexts({}, { enableEducationPlan: true }, { isCurrentWorkspaceManager: false })
- const user = userEvent.setup()
-
- render()
-
- const verifyButton = screen.getByText(/toVerified/i)
- await user.click(verifyButton)
-
- await waitFor(() => {
- expect(mockMutateAsync).toHaveBeenCalledTimes(1)
- expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
- })
- })
- })
-
- // ─── 3. Failed Verification Flow ────────────────────────────────────────
- describe('Failed verification flow', () => {
- it('should show VerifyStateModal with rejection info on error', async () => {
- mockMutateAsync.mockRejectedValue(new Error('Verification failed'))
- setupContexts({}, { enableEducationPlan: true })
- const user = userEvent.setup()
-
- render()
-
- // Modal should not be visible initially
- expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument()
-
- const verifyButton = screen.getByText(/toVerified/i)
- await user.click(verifyButton)
-
- // Modal should appear after verification failure
- await waitFor(() => {
- expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
- })
-
- // Modal should display rejection title and content
- expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i)
- expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i)
- })
-
- it('should show email and link in VerifyStateModal', async () => {
- mockMutateAsync.mockRejectedValue(new Error('fail'))
- setupContexts({}, { enableEducationPlan: true })
- const user = userEvent.setup()
-
- render()
-
- await user.click(screen.getByText(/toVerified/i))
-
- await waitFor(() => {
- expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu')
- expect(screen.getByTestId('modal-show-link')).toBeInTheDocument()
- })
- })
-
- it('should not redirect on verification failure', async () => {
- mockMutateAsync.mockRejectedValue(new Error('fail'))
+ // ─── 2. Temporarily Paused Verification Flow ────────────────────────────
+ describe('Temporarily paused verification flow', () => {
+ it('should show the pause notice without starting verification', async () => {
setupContexts({}, { enableEducationPlan: true })
const user = userEvent.setup()
@@ -280,12 +223,18 @@ describe('Education Verification Flow', () => {
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
})
- // Should NOT navigate
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(/educationDiscountPaused.title/i)
+ expect(screen.getByTestId('modal-content')).toHaveTextContent(
+ /educationDiscountPaused.description/i,
+ )
+ expect(screen.queryByTestId('modal-email')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('modal-show-link')).not.toBeInTheDocument()
+ expect(mockMutateAsync).not.toHaveBeenCalled()
expect(mockRouterPush).not.toHaveBeenCalled()
})
})
- // ─── 4. Education + Upgrade Coexistence ─────────────────────────────────
+ // ─── 3. Education + Upgrade Coexistence ─────────────────────────────────
describe('Education and upgrade button coexistence', () => {
it('should show both education verify and upgrade buttons for sandbox user', () => {
setupContexts({ type: Plan.sandbox }, { enableEducationPlan: true })
diff --git a/web/app/(commonLayout)/page.spec.tsx b/web/app/(commonLayout)/page.spec.tsx
new file mode 100644
index 00000000000..03aeba335c8
--- /dev/null
+++ b/web/app/(commonLayout)/page.spec.tsx
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ redirect: vi.fn((url: string) => {
+ throw new Error(`NEXT_REDIRECT:${url}`)
+ }),
+}))
+
+vi.mock('@/features/home/page', () => ({
+ HomePage: () => null,
+}))
+
+vi.mock('@/next/navigation', () => ({
+ redirect: (url: string) => mocks.redirect(url),
+}))
+
+describe('Home route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('opens billing for the legacy education verification action', async () => {
+ const { default: Page } = await import('./page')
+
+ await expect(
+ Page({
+ searchParams: Promise.resolve({
+ action: 'getEducationVerify',
+ utm_source: 'education-email',
+ }),
+ }),
+ ).rejects.toThrow('NEXT_REDIRECT')
+
+ expect(mocks.redirect).toHaveBeenCalledWith('/?settings=billing&utm_source=education-email')
+ })
+})
diff --git a/web/app/(commonLayout)/page.tsx b/web/app/(commonLayout)/page.tsx
index 5933845b8b4..16585981ce5 100644
--- a/web/app/(commonLayout)/page.tsx
+++ b/web/app/(commonLayout)/page.tsx
@@ -1,5 +1,47 @@
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { HomePage } from '@/features/home/page'
+import { redirect } from '@/next/navigation'
+
+type HomeSearchParams = Record
+
+type PageProps = {
+ searchParams?: Promise
+}
+
+const LEGACY_EDUCATION_VERIFY_ACTION = 'getEducationVerify'
+const SETTINGS_QUERY_PARAM_NAME = 'settings'
+
+const getFirstSearchParamValue = (value: string | string[] | undefined) => {
+ if (Array.isArray(value)) return value[0]
+
+ return value
+}
+
+const getEducationVerifyRedirectPath = (searchParams: HomeSearchParams) => {
+ const redirectSearchParams = new URLSearchParams({
+ [SETTINGS_QUERY_PARAM_NAME]: ACCOUNT_SETTING_TAB.BILLING,
+ })
+
+ Object.entries(searchParams).forEach(([key, value]) => {
+ if (key === 'action' || key === SETTINGS_QUERY_PARAM_NAME || value === undefined) return
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => redirectSearchParams.append(key, item))
+ return
+ }
+
+ redirectSearchParams.append(key, value)
+ })
+
+ return `/?${redirectSearchParams.toString()}`
+}
+
+export default async function Page({ searchParams }: PageProps) {
+ const resolvedSearchParams = (await searchParams) ?? {}
+ const action = getFirstSearchParamValue(resolvedSearchParams.action)
+
+ if (action === LEGACY_EDUCATION_VERIFY_ACTION)
+ redirect(getEducationVerifyRedirectPath(resolvedSearchParams))
-export default function Page() {
return
}
diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..caced120d0a
--- /dev/null
+++ b/web/app/components/billing/plan/__tests__/index.spec.tsx
@@ -0,0 +1,129 @@
+import { screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { baseProviderContextValue } from '@/context/provider-context'
+import { createConsoleQueryWrapper } from '@/test/console/query-data'
+import { render } from '@/test/console/render'
+import PlanComp from '../index'
+
+const mocks = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+ push: vi.fn(),
+}))
+
+vi.mock('@/context/account-state', async () => {
+ const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
+ return createAccountStateModuleMock(() => ({
+ userProfile: { email: 'user@example.com' },
+ }))
+})
+
+vi.mock('@/context/workspace-state', async () => {
+ const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
+ return createWorkspaceStateModuleMock(() => ({
+ isCurrentWorkspaceManager: false,
+ }))
+})
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useProviderContext: () => ({
+ ...baseProviderContextValue,
+ enableEducationPlan: true,
+ }),
+ }
+})
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({ push: mocks.push }),
+}))
+
+vi.mock('@/service/use-education', () => ({
+ useEducationVerify: () => ({
+ isPending: false,
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('@/app/components/billing/hooks/use-education-discount', () => ({
+ useEducationDiscount: () => ({
+ handleEducationDiscount: vi.fn(),
+ isEducationDiscountLoading: false,
+ }),
+}))
+
+vi.mock('@/app/components/billing/upgrade-btn', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/billing/usage-info', () => ({
+ default: () => null,
+}))
+
+vi.mock('@/app/components/billing/usage-info/apps-info', () => ({
+ default: () => null,
+}))
+
+vi.mock('@/app/components/billing/usage-info/vector-space-info', () => ({
+ default: () => null,
+}))
+
+vi.mock('../assets', () => ({
+ Enterprise: () => null,
+ Professional: () => null,
+ Sandbox: () => null,
+ Team: () => null,
+}))
+
+const renderPlan = (educationStatus = { allow_refresh: false, is_student: false }) => {
+ const { wrapper } = createConsoleQueryWrapper({
+ educationStatus,
+ systemFeatures: { deployment_edition: 'CLOUD' },
+ })
+
+ return render(, { wrapper })
+}
+
+describe('PlanComp education discount pause', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows education verification before View Plan when the original eligibility allows it', () => {
+ renderPlan()
+
+ const educationButton = screen.getByRole('button', { name: 'education.toVerified' })
+ const viewPlanButton = screen.getByRole('button', { name: 'View Plan' })
+
+ expect(
+ educationButton.compareDocumentPosition(viewPlanButton) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
+ })
+
+ it('hides education verification for a verified account that is not expiring', () => {
+ renderPlan({ allow_refresh: false, is_student: true })
+
+ expect(screen.queryByRole('button', { name: 'education.toVerified' })).not.toBeInTheDocument()
+ })
+
+ it('shows the pause notice instead of starting verification and closes it with OK', async () => {
+ const user = userEvent.setup()
+ renderPlan()
+
+ await user.click(screen.getByRole('button', { name: 'education.toVerified' }))
+
+ const dialog = await screen.findByRole('dialog')
+ expect(dialog).toHaveTextContent('education.educationDiscountPaused.title')
+ expect(dialog).toHaveTextContent('education.educationDiscountPaused.description')
+ expect(dialog).toHaveTextContent('education.educationDiscountPaused.thanks')
+ expect(dialog).toHaveTextContent('education.educationDiscountPaused.publishedAt')
+ expect(within(dialog).getAllByRole('button')).toHaveLength(1)
+ expect(within(dialog).getByRole('button')).toHaveAccessibleName('common.operation.ok')
+ expect(mocks.mutateAsync).not.toHaveBeenCalled()
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx
index 7b8e8b0e9e8..aa87dd50222 100644
--- a/web/app/components/billing/plan/index.tsx
+++ b/web/app/components/billing/plan/index.tsx
@@ -37,6 +37,9 @@ const selectEducationPlanStatus = ({ allow_refresh, is_student }: EducationStatu
isEducationAccount: is_student ?? false,
})
+// TODO: Remove this temporary gate once education applications and redemptions reopen.
+const EDUCATION_DISCOUNT_TEMPORARILY_PAUSED = true
+
const PlanComp: FC = ({ loc }) => {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
@@ -71,10 +74,17 @@ const PlanComp: FC = ({ loc }) => {
})()
const [showModal, setShowModal] = React.useState(false)
+ const [showEducationDiscountPausedModal, setShowEducationDiscountPausedModal] =
+ React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const { mutateAsync, isPending } = useEducationVerify()
const unmountedRef = useUnmountedRef()
const handleVerify = () => {
+ if (EDUCATION_DISCOUNT_TEMPORARILY_PAUSED) {
+ setShowEducationDiscountPausedModal(true)
+ return
+ }
+
if (isPending) return
mutateAsync()
.then((res) => {
@@ -175,6 +185,25 @@ const PlanComp: FC = ({ loc }) => {
resetInDays={apiRateLimitResetInDays}
/>
+ $['educationDiscountPaused.title'], { ns: 'education' })}
+ content={
+ <>
+
+ {t(($) => $['educationDiscountPaused.description'], { ns: 'education' })}
+
+
+ {t(($) => $['educationDiscountPaused.thanks'], { ns: 'education' })}
+
+
+ {t(($) => $['educationDiscountPaused.publishedAt'], { ns: 'education' })}
+
+ >
+ }
+ onConfirm={() => setShowEducationDiscountPausedModal(false)}
+ onCancel={() => setShowEducationDiscountPausedModal(false)}
+ />
({
useLogout: () => ({ mutateAsync: vi.fn() }),
}))
+vi.mock('@/service/use-education', () => ({
+ useEducationAutocomplete: () => ({
+ mutateAsync: vi.fn().mockResolvedValue({ data: [], has_next: false }),
+ isPending: false,
+ data: undefined,
+ }),
+}))
+
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
@@ -125,9 +133,9 @@ const setupContext = (isCurrentWorkspaceManager: boolean) => {
}
}
-const renderPage = () => {
+const renderPage = (isEducationAccount = true) => {
const { wrapper } = createConsoleQueryWrapper({
- educationStatus: { is_student: true },
+ educationStatus: { is_student: isEducationAccount },
workspacePermissionKeys: null,
})
return render(, {
@@ -221,4 +229,39 @@ describe('EducationApplyPage billing boundary', () => {
})
expect(globalThis.location.reload).not.toHaveBeenCalled()
})
+
+ it('requires every education agreement before submitting an application', async () => {
+ setupContext(true)
+ mockEducationAdd.mockResolvedValue({ message: 'success' })
+ const user = userEvent.setup()
+ renderPage(false)
+
+ const submitButton = screen.getByRole('button', { name: 'education.submit' })
+ await user.type(
+ screen.getByPlaceholderText('education.form.schoolName.placeholder'),
+ 'DifyUniversity',
+ )
+ await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.age' }))
+ await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.inSchool' }))
+
+ expect(submitButton).toBeDisabled()
+
+ await user.click(
+ screen.getByRole('checkbox', { name: 'education.form.terms.option.personalUse' }),
+ )
+
+ expect(submitButton).toBeEnabled()
+
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockEducationAdd.mock.calls[0]?.[0]).toEqual({
+ body: {
+ token: 'education-token',
+ role: 'Student',
+ institution: 'DifyUniversity',
+ },
+ })
+ })
+ })
})
diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx
index 090536d684a..1e18b8e8a58 100644
--- a/web/app/education-apply/education-apply-page.tsx
+++ b/web/app/education-apply/education-apply-page.tsx
@@ -36,6 +36,7 @@ const EducationApplyAgeContent = () => {
const [role, setRole] = useState('Student')
const [ageChecked, setAgeChecked] = useState(false)
const [inSchoolChecked, setInSchoolChecked] = useState(false)
+ const [personalUseChecked, setPersonalUseChecked] = useState(false)
const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false)
const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false)
const { isPending, mutateAsync: educationAdd } = useMutation(
@@ -262,7 +263,7 @@ const EducationApplyAgeContent = () => {
/>
{t(($) => $['form.terms.option.age'], { ns: 'education' })}
-