From 74cd175d37690c7078cda59e1ef8420369d291a7 Mon Sep 17 00:00:00 2001 From: EYHN Date: Thu, 5 Sep 2024 23:56:58 +0800 Subject: [PATCH] feat(core): invoice service (#8124) --- .../general-setting/billing/index.tsx | 93 +++---- .../new-workspace-setting-detail/members.tsx | 39 +-- .../src/hooks/affine/use-is-shared-page.tsx | 232 ------------------ .../core/src/hooks/use-workspace-features.ts | 76 ------ .../src/modules/cloud/entities/invoices.ts | 78 ++++++ .../frontend/core/src/modules/cloud/index.ts | 8 + .../src/modules/cloud/services/invoices.ts | 7 + .../core/src/modules/cloud/stores/invoices.ts | 24 ++ .../modules/permissions/entities/members.ts | 63 ++--- .../frontend/graphql/src/graphql/index.ts | 1 + .../frontend/graphql/src/graphql/invoices.gql | 1 + packages/frontend/graphql/src/schema.ts | 1 + 12 files changed, 229 insertions(+), 394 deletions(-) delete mode 100644 packages/frontend/core/src/hooks/affine/use-is-shared-page.tsx delete mode 100644 packages/frontend/core/src/hooks/use-workspace-features.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/invoices.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/invoices.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/invoices.ts diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index f97402e2d0..167d516310 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -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']()} /> - - }> - - - - - - - }> - - - - - + + + + + + ); }; @@ -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 ; + } else { + return ( + + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} + + ); + } + } return (
- {invoices.length === 0 ? ( + {invoiceCount === 0 ? (

{t['com.affine.payment.billing-setting.no-invoice']()}

) : ( - invoices.map(invoice => ( + pageInvoices?.map(invoice => ( )) )}
- {invoiceCount > INVOICE_PAGE_SIZE && ( + {invoiceCount > invoicesService.invoices.PAGE_SIZE && ( setSkip(skip)} + countPerPage={invoicesService.invoices.PAGE_SIZE} + pageNum={pageNum} + onPageChange={handlePageChange} /> )}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx index 5634fd4ec1..64a6f24fc6 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx @@ -162,15 +162,15 @@ export const CloudWorkspaceMembersPanel = () => { if (workspaceQuota === null) { if (isLoading) { return ; - } - if (error) { + } else { return ( - {UserFriendlyError.fromAnyError(error).message} + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} ); } - 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 (
- {isLoading && pageMembers === undefined ? ( - + {pageMembers === undefined ? ( + isLoading ? ( + + ) : ( + + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} + + ) ) : ( pageMembers?.map(member => ( ; - -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: ( - - ), - }); - 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: ( - - ), - }); - 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: , - }); - 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, - }; -} diff --git a/packages/frontend/core/src/hooks/use-workspace-features.ts b/packages/frontend/core/src/hooks/use-workspace-features.ts deleted file mode 100644 index 3587a4e30e..0000000000 --- a/packages/frontend/core/src/hooks/use-workspace-features.ts +++ /dev/null @@ -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, - }; -}; diff --git a/packages/frontend/core/src/modules/cloud/entities/invoices.ts b/packages/frontend/core/src/modules/cloud/entities/invoices.ts new file mode 100644 index 0000000000..04d6b26c9b --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/invoices.ts @@ -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(undefined); + pageInvoices$ = new LiveData(undefined); + + isLoading$ = new LiveData(false); + error$ = new LiveData(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(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index add141d3cc..8360ef699e 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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) diff --git a/packages/frontend/core/src/modules/cloud/services/invoices.ts b/packages/frontend/core/src/modules/cloud/services/invoices.ts new file mode 100644 index 0000000000..6e1dd2306c --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/invoices.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { Invoices } from '../entities/invoices'; + +export class InvoicesService extends Service { + invoices = this.framework.createEntity(Invoices); +} diff --git a/packages/frontend/core/src/modules/cloud/stores/invoices.ts b/packages/frontend/core/src/modules/cloud/stores/invoices.ts new file mode 100644 index 0000000000..96192b3c3f --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/invoices.ts @@ -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; + } +} diff --git a/packages/frontend/core/src/modules/permissions/entities/members.ts b/packages/frontend/core/src/modules/permissions/entities/members.ts index 5064fe38e1..966cb71446 100644 --- a/packages/frontend/core/src/modules/permissions/entities/members.ts +++ b/packages/frontend/core/src/modules/permissions/entities/members.ts @@ -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(), - 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) { diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 43206d73bd..47a97eb632 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -750,6 +750,7 @@ export const invoicesQuery = { query: ` query invoices($take: Int!, $skip: Int!) { currentUser { + invoiceCount invoices(take: $take, skip: $skip) { id status diff --git a/packages/frontend/graphql/src/graphql/invoices.gql b/packages/frontend/graphql/src/graphql/invoices.gql index bd26f0896a..0645d6a3b8 100644 --- a/packages/frontend/graphql/src/graphql/invoices.gql +++ b/packages/frontend/graphql/src/graphql/invoices.gql @@ -1,5 +1,6 @@ query invoices($take: Int!, $skip: Int!) { currentUser { + invoiceCount invoices(take: $take, skip: $skip) { id status diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 0ffe888349..2dcfd2233e 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -1941,6 +1941,7 @@ export type InvoicesQuery = { __typename?: 'Query'; currentUser: { __typename?: 'UserType'; + invoiceCount: number; invoices: Array<{ __typename?: 'UserInvoice'; id: string;