feat: trigger billing (#28335)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Maries
2025-11-20 10:15:23 +08:00
committed by GitHub
parent c0b7ffd5d0
commit a1b735a4c0
61 changed files with 1475 additions and 465 deletions

View File

@@ -0,0 +1,130 @@
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config'
import type { ModalState } from '../modal-context'
export type TriggerEventsLimitModalPayload = {
usage: number
total: number
resetInDays?: number
planType: Plan
storageKey?: string
persistDismiss?: boolean
}
type TriggerPlanInfo = {
type: Plan
usage: { triggerEvents: number }
total: { triggerEvents: number }
reset: { triggerEvents?: number | null }
}
type UseTriggerEventsLimitModalOptions = {
plan: TriggerPlanInfo
isFetchedPlan: boolean
currentWorkspaceId?: string
}
type UseTriggerEventsLimitModalResult = {
showTriggerEventsLimitModal: ModalState<TriggerEventsLimitModalPayload> | null
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
persistTriggerEventsLimitModalDismiss: () => void
}
const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed'
export const useTriggerEventsLimitModal = ({
plan,
isFetchedPlan,
currentWorkspaceId,
}: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => {
const [showTriggerEventsLimitModal, setShowTriggerEventsLimitModal] = useState<ModalState<TriggerEventsLimitModalPayload> | null>(null)
const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({})
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
if (typeof window === 'undefined')
return
if (!currentWorkspaceId)
return
if (!isFetchedPlan) {
setShowTriggerEventsLimitModal(null)
return
}
const { type, usage, total, reset } = plan
const isUnlimited = total.triggerEvents === NUM_INFINITE
const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents
if (type === Plan.team || isUnlimited || !reachedLimit) {
if (showTriggerEventsLimitModal)
setShowTriggerEventsLimitModal(null)
return
}
const triggerResetInDays = type === Plan.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')
return 'none'
})()
const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}`
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey])
return
let persistDismiss = true
let hasDismissed = false
try {
if (localStorage.getItem(storageKey) === '1')
hasDismissed = true
}
catch {
persistDismiss = false
}
if (hasDismissed)
return
if (showTriggerEventsLimitModal?.payload.storageKey === storageKey)
return
setShowTriggerEventsLimitModal({
payload: {
usage: usage.triggerEvents,
total: total.triggerEvents,
planType: type,
resetInDays: triggerResetInDays,
storageKey,
persistDismiss,
},
})
}, [plan, isFetchedPlan, showTriggerEventsLimitModal, currentWorkspaceId])
const persistTriggerEventsLimitModalDismiss = useCallback(() => {
const storageKey = showTriggerEventsLimitModal?.payload.storageKey
if (!storageKey)
return
if (showTriggerEventsLimitModal?.payload.persistDismiss) {
try {
localStorage.setItem(storageKey, '1')
return
}
catch {
// ignore error and fall back to in-memory guard
}
}
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
}, [showTriggerEventsLimitModal])
return {
showTriggerEventsLimitModal,
setShowTriggerEventsLimitModal,
persistTriggerEventsLimitModalDismiss,
}
}

View File

@@ -0,0 +1,181 @@
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { ModalContextProvider } from '@/context/modal-context'
import { Plan } from '@/app/components/billing/type'
import { defaultPlan } from '@/app/components/billing/config'
jest.mock('@/config', () => {
const actual = jest.requireActual('@/config')
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
jest.mock('next/navigation', () => ({
useSearchParams: jest.fn(() => new URLSearchParams()),
}))
const mockUseProviderContext = jest.fn()
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
const mockUseAppContext = jest.fn()
jest.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
let latestTriggerEventsModalProps: any = null
const triggerEventsLimitModalMock = jest.fn((props: any) => {
latestTriggerEventsModalProps = props
return (
<div data-testid="trigger-limit-modal">
<button type="button" onClick={props.onDismiss}>dismiss</button>
<button type="button" onClick={props.onUpgrade}>upgrade</button>
</div>
)
})
jest.mock('@/app/components/billing/trigger-events-limit-modal', () => ({
__esModule: true,
default: (props: any) => triggerEventsLimitModalMock(props),
}))
type DefaultPlanShape = typeof defaultPlan
type PlanOverrides = Partial<Omit<DefaultPlanShape, 'usage' | 'total' | 'reset'>> & {
usage?: Partial<DefaultPlanShape['usage']>
total?: Partial<DefaultPlanShape['total']>
reset?: Partial<DefaultPlanShape['reset']>
}
const createPlan = (overrides: PlanOverrides = {}): DefaultPlanShape => ({
...defaultPlan,
...overrides,
usage: {
...defaultPlan.usage,
...overrides.usage,
},
total: {
...defaultPlan.total,
...overrides.total,
},
reset: {
...defaultPlan.reset,
...overrides.reset,
},
})
const renderProvider = () => render(
<ModalContextProvider>
<div data-testid="modal-context-test-child" />
</ModalContextProvider>,
)
describe('ModalContextProvider trigger events limit modal', () => {
beforeEach(() => {
latestTriggerEventsModalProps = null
triggerEventsLimitModalMock.mockClear()
mockUseAppContext.mockReset()
mockUseProviderContext.mockReset()
window.localStorage.clear()
mockUseAppContext.mockReturnValue({
currentWorkspace: {
id: 'workspace-1',
},
})
})
afterEach(() => {
jest.restoreAllMocks()
})
it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 3000 },
total: { triggerEvents: 3000 },
reset: { triggerEvents: 5 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
expect(latestTriggerEventsModalProps).toMatchObject({
usage: 3000,
total: 3000,
resetInDays: 5,
planType: Plan.professional,
})
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
const [key, value] = setItemSpy.mock.calls[0]
expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-')
expect(value).toBe('1')
})
it('relies on the in-memory guard when localStorage reads throw', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 200 },
total: { triggerEvents: 200 },
reset: { triggerEvents: 3 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage disabled')
})
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
expect(setItemSpy).not.toHaveBeenCalled()
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
})
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 120 },
total: { triggerEvents: 120 },
reset: { triggerEvents: 2 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Quota exceeded')
})
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
})
})

View File

@@ -36,6 +36,12 @@ import { noop } from 'lodash-es'
import dynamic from 'next/dynamic'
import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal'
import type { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useProviderContext } from '@/context/provider-context'
import { useAppContext } from '@/context/app-context'
import {
type TriggerEventsLimitModalPayload,
useTriggerEventsLimitModal,
} from './hooks/use-trigger-events-limit-modal'
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
@@ -74,6 +80,9 @@ const UpdatePlugin = dynamic(() => import('@/app/components/plugins/update-plugi
const ExpireNoticeModal = dynamic(() => import('@/app/education-apply/expire-notice-modal'), {
ssr: false,
})
const TriggerEventsLimitModal = dynamic(() => import('@/app/components/billing/trigger-events-limit-modal'), {
ssr: false,
})
export type ModalState<T> = {
payload: T
@@ -113,6 +122,7 @@ export type ModalContextState = {
}> | null>>
setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>>
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
}
const PRICING_MODAL_QUERY_PARAM = 'pricing'
const PRICING_MODAL_QUERY_VALUE = 'open'
@@ -130,6 +140,7 @@ const ModalContext = createContext<ModalContextState>({
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
})
export const useModalContext = () => useContext(ModalContext)
@@ -168,6 +179,7 @@ export const ModalContextProvider = ({
}> | null>(null)
const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null)
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
const { currentWorkspace } = useAppContext()
const [showPricingModal, setShowPricingModal] = useState(
searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE,
@@ -228,6 +240,17 @@ export const ModalContextProvider = ({
window.history.replaceState(null, '', url.toString())
}, [showPricingModal])
const { plan, isFetchedPlan } = useProviderContext()
const {
showTriggerEventsLimitModal,
setShowTriggerEventsLimitModal,
persistTriggerEventsLimitModalDismiss,
} = useTriggerEventsLimitModal({
plan,
isFetchedPlan,
currentWorkspaceId: currentWorkspace?.id,
})
const handleCancelModerationSettingModal = () => {
setShowModerationSettingModal(null)
if (showModerationSettingModal?.onCancelCallback)
@@ -334,6 +357,7 @@ export const ModalContextProvider = ({
setShowOpeningModal,
setShowUpdatePluginModal,
setShowEducationExpireNoticeModal,
setShowTriggerEventsLimitModal,
}}>
<>
{children}
@@ -455,6 +479,25 @@ export const ModalContextProvider = ({
onClose={() => setShowEducationExpireNoticeModal(null)}
/>
)}
{
!!showTriggerEventsLimitModal && (
<TriggerEventsLimitModal
show
usage={showTriggerEventsLimitModal.payload.usage}
total={showTriggerEventsLimitModal.payload.total}
planType={showTriggerEventsLimitModal.payload.planType}
resetInDays={showTriggerEventsLimitModal.payload.resetInDays}
onDismiss={() => {
persistTriggerEventsLimitModalDismiss()
setShowTriggerEventsLimitModal(null)
}}
onUpgrade={() => {
persistTriggerEventsLimitModalDismiss()
setShowTriggerEventsLimitModal(null)
handleShowPricingModal()
}}
/>
)}
</>
</ModalContext.Provider>
)

View File

@@ -17,7 +17,7 @@ import {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Model, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RETRIEVE_METHOD } from '@/types/app'
import type { Plan } from '@/app/components/billing/type'
import type { Plan, UsageResetInfo } from '@/app/components/billing/type'
import type { UsagePlanInfo } from '@/app/components/billing/type'
import { fetchCurrentPlanInfo } from '@/service/billing'
import { parseCurrentPlan } from '@/app/components/billing/utils'
@@ -40,6 +40,7 @@ type ProviderContextState = {
type: Plan
usage: UsagePlanInfo
total: UsagePlanInfo
reset: UsageResetInfo
}
isFetchedPlan: boolean
enableBilling: boolean