feat(core): invoice service (#8124)

This commit is contained in:
EYHN
2024-09-05 23:56:58 +08:00
committed by GitHub
parent 9cbe416c2c
commit 74cd175d37
12 changed files with 229 additions and 394 deletions

View File

@@ -10,31 +10,33 @@ import { Loading } from '@affine/component/ui/loading';
import { getUpgradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { track } from '@affine/core/mixpanel';
import {
AuthService,
InvoicesService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import type { InvoicesQuery } from '@affine/graphql';
import {
createCustomerPortalMutation,
getInvoicesCountQuery,
invoicesQuery,
InvoiceStatus,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
UserFriendlyError,
} from '@affine/graphql';
import { i18nTime, Trans, useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
openSettingModalAtom,
type PlansScrollAnchor,
} from '../../../../../atoms';
import { useMutation } from '../../../../../hooks/use-mutation';
import { useQuery } from '../../../../../hooks/use-query';
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
import { popupWindow } from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
import { CancelAction, ResumeAction } from '../plans/actions';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
import { BelieverCard } from '../plans/lifetime/believer-card';
@@ -48,8 +50,6 @@ enum DescriptionI18NKey {
Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime',
}
const INVOICE_PAGE_SIZE = 12;
const getMessageKey = (
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
@@ -69,24 +69,14 @@ export const BillingSettings = () => {
title={t['com.affine.payment.billing-setting.title']()}
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
/>
<SWRErrorBoundary FallbackComponent={SubscriptionSettingSkeleton}>
<Suspense fallback={<SubscriptionSettingSkeleton />}>
<SettingWrapper
title={t['com.affine.payment.billing-setting.information']()}
>
<SubscriptionSettings />
</SettingWrapper>
</Suspense>
</SWRErrorBoundary>
<SWRErrorBoundary FallbackComponent={BillingHistorySkeleton}>
<Suspense fallback={<BillingHistorySkeleton />}>
<SettingWrapper
title={t['com.affine.payment.billing-setting.history']()}
>
<BillingHistory />
</SettingWrapper>
</Suspense>
</SWRErrorBoundary>
<SettingWrapper
title={t['com.affine.payment.billing-setting.information']()}
>
<SubscriptionSettings />
</SettingWrapper>
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<BillingHistory />
</SettingWrapper>
</>
);
};
@@ -485,39 +475,60 @@ const CancelSubscription = ({ loading }: { loading?: boolean }) => {
const BillingHistory = () => {
const t = useI18n();
const { data: invoicesCountQueryResult } = useQuery({
query: getInvoicesCountQuery,
});
const [skip, setSkip] = useState(0);
const invoicesService = useService(InvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
const { data: invoicesQueryResult } = useQuery({
query: invoicesQuery,
variables: { skip, take: INVOICE_PAGE_SIZE },
});
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const invoices = invoicesQueryResult.currentUser?.invoices ?? [];
const invoiceCount = invoicesCountQueryResult.currentUser?.invoiceCount ?? 0;
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoices.length === 0 ? (
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
invoices.map(invoice => (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > INVOICE_PAGE_SIZE && (
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={INVOICE_PAGE_SIZE}
onPageChange={skip => setSkip(skip)}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>

View File

@@ -162,15 +162,15 @@ export const CloudWorkspaceMembersPanel = () => {
if (workspaceQuota === null) {
if (isLoading) {
return <MembersPanelFallback />;
}
if (error) {
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{UserFriendlyError.fromAnyError(error).message}
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
return; // never reach here
}
return (
@@ -261,6 +261,7 @@ const MemberList = ({
const memberCount = useLiveData(membersService.members.memberCount$);
const pageNum = useLiveData(membersService.members.pageNum$);
const isLoading = useLiveData(membersService.members.isLoading$);
const error = useLiveData(membersService.members.error$);
const pageMembers = useLiveData(membersService.members.pageMembers$);
useEffect(() => {
@@ -280,17 +281,25 @@ const MemberList = ({
return (
<div className={style.memberList}>
{isLoading && pageMembers === undefined ? (
<MemberListFallback
memberCount={
memberCount
? Math.max(
memberCount - pageNum * membersService.members.PAGE_SIZE,
1
)
: 1
}
/>
{pageMembers === undefined ? (
isLoading ? (
<MemberListFallback
memberCount={
memberCount
? Math.max(
memberCount - pageNum * membersService.members.PAGE_SIZE,
1
)
: 1
}
/>
) : (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
)
) : (
pageMembers?.map(member => (
<MemberItem

View File

@@ -1,232 +0,0 @@
import { notify } from '@affine/component';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
getWorkspacePublicPagesQuery,
PublicPageMode,
publishPageMutation,
revokePublicPageMutation,
} from '@affine/graphql';
import { type I18nKeys, useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/blocks';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons/rc';
import { type Workspace } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useMemo } from 'react';
import { useMutation } from '../use-mutation';
import { useQuery } from '../use-query';
type NotificationKey =
| 'enableSuccessTitle'
| 'enableSuccessMessage'
| 'enableErrorTitle'
| 'enableErrorMessage'
| 'changeSuccessTitle'
| 'changeErrorTitle'
| 'changeErrorMessage'
| 'disableSuccessTitle'
| 'disableSuccessMessage'
| 'disableErrorTitle'
| 'disableErrorMessage';
const notificationToI18nKey = {
enableSuccessTitle:
'com.affine.share-menu.create-public-link.notification.success.title',
enableSuccessMessage:
'com.affine.share-menu.create-public-link.notification.success.message',
enableErrorTitle:
'com.affine.share-menu.create-public-link.notification.fail.title',
enableErrorMessage:
'com.affine.share-menu.create-public-link.notification.fail.message',
changeSuccessTitle:
'com.affine.share-menu.confirm-modify-mode.notification.success.title',
changeErrorTitle:
'com.affine.share-menu.confirm-modify-mode.notification.fail.title',
changeErrorMessage:
'com.affine.share-menu.confirm-modify-mode.notification.fail.message',
disableSuccessTitle:
'com.affine.share-menu.disable-publish-link.notification.success.title',
disableSuccessMessage:
'com.affine.share-menu.disable-publish-link.notification.success.message',
disableErrorTitle:
'com.affine.share-menu.disable-publish-link.notification.fail.title',
disableErrorMessage:
'com.affine.share-menu.disable-publish-link.notification.fail.message',
} satisfies Record<NotificationKey, I18nKeys>;
export function useIsSharedPage(
workspaceId: string,
pageId: string
): {
isSharedPage: boolean;
changeShare: (mode: DocMode) => void;
disableShare: () => void;
currentShareMode: DocMode;
enableShare: (mode: DocMode) => void;
} {
const t = useI18n();
const { data, mutate } = useQuery({
query: getWorkspacePublicPagesQuery,
variables: {
workspaceId,
},
});
const { trigger: enableSharePage } = useMutation({
mutation: publishPageMutation,
});
const { trigger: disableSharePage } = useMutation({
mutation: revokePublicPageMutation,
});
const [isSharedPage, currentShareMode] = useMemo(() => {
const publicPage = data?.workspace.publicPages.find(
publicPage => publicPage.id === pageId
);
const isPageShared = !!publicPage;
const currentShareMode: DocMode =
publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page';
return [isPageShared, currentShareMode];
}, [data?.workspace.publicPages, pageId]);
const enableShare = useCallback(
(mode: DocMode) => {
const publishMode =
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
enableSharePage({ workspaceId, pageId, mode: publishMode })
.then(() => {
notify.success({
title: t[notificationToI18nKey['enableSuccessTitle']](),
message: t[notificationToI18nKey['enableSuccessMessage']](),
style: 'normal',
icon: (
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
),
});
return mutate();
})
.catch(e => {
notify.error({
title: t[notificationToI18nKey['enableErrorTitle']](),
message: t[notificationToI18nKey['enableErrorMessage']](),
});
console.error(e);
});
},
[enableSharePage, mutate, pageId, t, workspaceId]
);
const changeShare = useCallback(
(mode: DocMode) => {
const publishMode =
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
enableSharePage({ workspaceId, pageId, mode: publishMode })
.then(() => {
notify.success({
title: t[notificationToI18nKey['changeSuccessTitle']](),
message: t[
'com.affine.share-menu.confirm-modify-mode.notification.success.message'
]({
preMode:
publishMode === PublicPageMode.Edgeless
? t['Page']()
: t['Edgeless'](),
currentMode:
publishMode === PublicPageMode.Edgeless
? t['Edgeless']()
: t['Page'](),
}),
style: 'normal',
icon: (
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
),
});
return mutate();
})
.catch(e => {
notify.error({
title: t[notificationToI18nKey['changeErrorTitle']](),
message: t[notificationToI18nKey['changeErrorMessage']](),
});
console.error(e);
});
},
[enableSharePage, mutate, pageId, t, workspaceId]
);
const disableShare = useCallback(() => {
disableSharePage({ workspaceId, pageId })
.then(() => {
notify.success({
title: t[notificationToI18nKey['disableSuccessTitle']](),
message: t[notificationToI18nKey['disableSuccessMessage']](),
style: 'normal',
icon: <SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />,
});
return mutate();
})
.catch(e => {
notify.error({
title: t[notificationToI18nKey['disableErrorTitle']](),
message: t[notificationToI18nKey['disableErrorMessage']](),
});
console.error(e);
});
}, [disableSharePage, mutate, pageId, t, workspaceId]);
return useMemo(
() => ({
isSharedPage,
currentShareMode,
enableShare,
disableShare,
changeShare,
}),
[isSharedPage, currentShareMode, enableShare, disableShare, changeShare]
);
}
export function usePublicPages(workspace: Workspace) {
const isLocalWorkspace = workspace.flavour === WorkspaceFlavour.LOCAL;
const { data } = useQuery(
isLocalWorkspace
? undefined
: {
query: getWorkspacePublicPagesQuery,
variables: {
workspaceId: workspace.id,
},
}
);
const maybeData = data as typeof data | undefined;
const publicPages: {
id: string;
mode: DocMode;
}[] = useMemo(
() =>
maybeData?.workspace.publicPages.map(i => ({
id: i.id,
mode: i.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page',
})) ?? [],
[maybeData?.workspace.publicPages]
);
/**
* Return `undefined` if the page is not public.
*/
const getPublicMode = useCallback(
(pageId: string) => {
return publicPages.find(i => i.id === pageId)?.mode;
},
[publicPages]
);
return {
publicPages,
getPublicMode,
};
}

View File

@@ -1,76 +0,0 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import type { FeatureType } from '@affine/graphql';
import {
availableFeaturesQuery,
enabledFeaturesQuery,
setWorkspaceExperimentalFeatureMutation,
} from '@affine/graphql';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useAsyncCallback } from './affine-async-hooks';
import { useMutateQueryResource, useMutation } from './use-mutation';
import { useQueryImmutable } from './use-query';
const emptyFeatures: FeatureType[] = [];
export const useWorkspaceAvailableFeatures = (
workspaceMetadata: WorkspaceMetadata
) => {
const isCloudWorkspace =
workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const { data } = useQueryImmutable(
isCloudWorkspace
? {
query: availableFeaturesQuery,
variables: {
id: workspaceMetadata.id,
},
}
: undefined
);
return data?.workspace.availableFeatures ?? emptyFeatures;
};
export const useWorkspaceEnabledFeatures = (
workspaceMetadata: WorkspaceMetadata
) => {
const isCloudWorkspace =
workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const { data } = useQueryImmutable(
isCloudWorkspace
? {
query: enabledFeaturesQuery,
variables: {
id: workspaceMetadata.id,
},
}
: undefined
);
return data?.workspace.features ?? emptyFeatures;
};
export const useSetWorkspaceFeature = (
workspaceMetadata: WorkspaceMetadata
) => {
const { trigger, isMutating } = useMutation({
mutation: setWorkspaceExperimentalFeatureMutation,
});
const revalidate = useMutateQueryResource();
return {
trigger: useAsyncCallback(
async (feature: FeatureType, enable: boolean) => {
await trigger({
workspaceId: workspaceMetadata.id,
feature,
enable,
});
await revalidate(enabledFeaturesQuery, vars => {
return vars.id === workspaceMetadata.id;
});
},
[workspaceMetadata.id, revalidate, trigger]
),
isMutating,
};
};

View File

@@ -0,0 +1,78 @@
import type { InvoicesQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { InvoicesStore } from '../stores/invoices';
export type Invoice = NonNullable<
InvoicesQuery['currentUser']
>['invoices'][number];
export class Invoices extends Entity {
constructor(private readonly store: InvoicesStore) {
super();
}
pageNum$ = new LiveData(0);
invoiceCount$ = new LiveData<number | undefined>(undefined);
pageInvoices$ = new LiveData<Invoice[] | undefined>(undefined);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly PAGE_SIZE = 8;
readonly revalidate = effect(
map(() => this.pageNum$.value),
exhaustMapSwitchUntilChanged(
(a, b) => a === b,
pageNum => {
return fromPromise(async signal => {
return this.store.fetchInvoices(
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
);
}).pipe(
mergeMap(data => {
this.invoiceCount$.setValue(data.invoiceCount);
this.pageInvoices$.setValue(data.invoices);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.pageInvoices$.setValue(undefined);
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
}
)
);
setPageNum(pageNum: number) {
this.pageNum$.setValue(pageNum);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -1,3 +1,4 @@
export type { Invoice } from './entities/invoices';
export type { AuthAccountInfo } from './entities/session';
export {
BackendError,
@@ -8,6 +9,7 @@ export {
export { AccountChanged, AuthService } from './services/auth';
export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { InvoicesService } from './services/invoices';
export { ServerConfigService } from './services/server-config';
export { SubscriptionService } from './services/subscription';
export { UserCopilotQuotaService } from './services/user-copilot-quota';
@@ -25,6 +27,7 @@ import {
} from '@toeverything/infra';
import { CloudDocMeta } from './entities/cloud-doc-meta';
import { Invoices } from './entities/invoices';
import { ServerConfig } from './entities/server-config';
import { AuthSession } from './entities/session';
import { Subscription } from './entities/subscription';
@@ -36,6 +39,7 @@ import { AuthService } from './services/auth';
import { CloudDocMetaService } from './services/cloud-doc-meta';
import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql';
import { InvoicesService } from './services/invoices';
import { ServerConfigService } from './services/server-config';
import { SubscriptionService } from './services/subscription';
import { UserCopilotQuotaService } from './services/user-copilot-quota';
@@ -44,6 +48,7 @@ import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket';
import { AuthStore } from './stores/auth';
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
import { InvoicesStore } from './stores/invoices';
import { ServerConfigStore } from './stores/server-config';
import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
@@ -78,6 +83,9 @@ export function configureCloudModule(framework: Framework) {
.service(UserFeatureService)
.entity(UserFeature, [AuthService, UserFeatureStore])
.store(UserFeatureStore, [GraphQLService])
.service(InvoicesService)
.store(InvoicesStore, [GraphQLService])
.entity(Invoices, [InvoicesStore])
.scope(WorkspaceScope)
.scope(DocScope)
.service(CloudDocMetaService)

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { Invoices } from '../entities/invoices';
export class InvoicesService extends Service {
invoices = this.framework.createEntity(Invoices);
}

View File

@@ -0,0 +1,24 @@
import { invoicesQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class InvoicesStore extends Store {
constructor(private readonly graphqlService: GraphQLService) {
super();
}
async fetchInvoices(skip: number, take: number, signal?: AbortSignal) {
const data = await this.graphqlService.gql({
query: invoicesQuery,
variables: { skip, take },
context: { signal },
});
if (!data.currentUser) {
throw new Error('No logged in');
}
return data.currentUser;
}
}

View File

@@ -5,12 +5,13 @@ import {
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { distinctUntilChanged, EMPTY, map, mergeMap, switchMap } from 'rxjs';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceMembersStore } from '../stores/members';
@@ -37,36 +38,38 @@ export class WorkspaceMembers extends Entity {
readonly revalidate = effect(
map(() => this.pageNum$.value),
distinctUntilChanged<number>(),
switchMap(pageNum => {
return fromPromise(async signal => {
return this.store.fetchMembers(
this.workspaceService.workspace.id,
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
exhaustMapSwitchUntilChanged(
(a, b) => a === b,
pageNum => {
return fromPromise(async signal => {
return this.store.fetchMembers(
this.workspaceService.workspace.id,
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
);
}).pipe(
mergeMap(data => {
this.memberCount$.setValue(data.memberCount);
this.pageMembers$.setValue(data.members);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
}).pipe(
mergeMap(data => {
this.memberCount$.setValue(data.memberCount);
this.pageMembers$.setValue(data.members);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
}
)
);
setPageNum(pageNum: number) {