From 858a1da35fab4c71b53f5a38d0b1b073fb9b744a Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 20 Oct 2023 09:42:33 +0800 Subject: [PATCH] feat(core): impl billing settings (#4652) --- .../migration.sql | 2 + packages/backend/server/schema.prisma | 2 + .../server/src/modules/payment/resolver.ts | 10 + .../server/src/modules/payment/service.ts | 24 ++ packages/backend/server/src/schema.gql | 4 + .../setting-components/setting-row.tsx | 14 +- .../setting-components/share.css.ts | 1 - .../general-setting/billing/index.tsx | 272 ++++++++++++++++++ .../general-setting/billing/style.css.ts | 39 +++ .../setting-modal/general-setting/index.tsx | 29 +- .../general-setting/plans/index.tsx | 32 ++- .../src/graphql/create-customer-portal.gql | 3 + .../frontend/graphql/src/graphql/index.ts | 29 ++ .../frontend/graphql/src/graphql/invoices.gql | 1 + .../src/graphql/resume-subscription.gql | 9 + packages/frontend/graphql/src/schema.ts | 36 +++ 16 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts create mode 100644 packages/frontend/graphql/src/graphql/create-customer-portal.gql create mode 100644 packages/frontend/graphql/src/graphql/resume-subscription.gql diff --git a/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql b/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql new file mode 100644 index 0000000000..91729f5aa2 --- /dev/null +++ b/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index bea9b7f8d5..41c4dab28d 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -224,6 +224,8 @@ model UserInvoice { // billing reason reason String @db.VarChar lastPaymentError String? @map("last_payment_error") @db.Text + // stripe hosted invoice link + link String? @db.Text user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index e5e1a77a3c..84e926ded6 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -117,6 +117,9 @@ class UserInvoiceType implements Partial { @Field(() => String, { nullable: true }) lastPaymentError?: string | null; + @Field(() => String, { nullable: true }) + link?: string | null; + @Field(() => Date) createdAt!: Date; @@ -183,6 +186,13 @@ export class SubscriptionResolver { return session.url; } + @Mutation(() => String, { + description: 'Create a stripe customer portal to manage payment methods', + }) + async createCustomerPortal(@CurrentUser() user: User) { + return this.service.createCustomerPortal(user.id); + } + @Mutation(() => UserSubscriptionType) async cancelSubscription(@CurrentUser() user: User) { return this.service.cancelSubscription(user.id); diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index 8f9771b1bd..018c81b435 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -296,6 +296,29 @@ export class SubscriptionService { } } + async createCustomerPortal(id: string) { + const user = await this.db.userStripeCustomer.findUnique({ + where: { + userId: id, + }, + }); + + if (!user) { + throw new Error('Unknown user'); + } + + try { + const portal = await this.stripe.billingPortal.sessions.create({ + customer: user.stripeCustomerId, + }); + + return portal.url; + } catch (e) { + this.logger.error('Failed to create customer portal.', e); + throw new Error('Failed to create customer portal'); + } + } + @OnEvent('customer.subscription.created') @OnEvent('customer.subscription.updated') async onSubscriptionChanges(subscription: Stripe.Subscription) { @@ -519,6 +542,7 @@ export class SubscriptionService { currency: stripeInvoice.currency, amount: stripeInvoice.total, status: stripeInvoice.status ?? InvoiceStatus.Void, + link: stripeInvoice.hosted_invoice_url, }; // handle payment error diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 6be3ba621e..5b62bdaa07 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -112,6 +112,7 @@ type UserInvoice { status: InvoiceStatus! reason: String! lastPaymentError: String + link: String createdAt: DateTime! updatedAt: DateTime! } @@ -278,6 +279,9 @@ type Mutation { """Create a subscription checkout link of stripe""" checkout(recurring: SubscriptionRecurring!): String! + + """Create a stripe customer portal to manage payment methods""" + createCustomerPortal: String! cancelSubscription: UserSubscription! resumeSubscription: UserSubscription! updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription! diff --git a/packages/frontend/component/src/components/setting-components/setting-row.tsx b/packages/frontend/component/src/components/setting-components/setting-row.tsx index 4a6dc3f65f..df0a100709 100644 --- a/packages/frontend/component/src/components/setting-components/setting-row.tsx +++ b/packages/frontend/component/src/components/setting-components/setting-row.tsx @@ -11,6 +11,7 @@ export type SettingRowProps = PropsWithChildren<{ spreadCol?: boolean; 'data-testid'?: string; disabled?: boolean; + className?: string; }>; export const SettingRow = ({ @@ -21,14 +22,19 @@ export const SettingRow = ({ style, spreadCol = true, disabled = false, + className, ...props }: PropsWithChildren) => { return (
{ + const status = useCurrentLoginStatus(); + + if (status !== 'authenticated') { + return null; + } + + return ( + <> + + {/* TODO: loading fallback */} + + + + + + {/* TODO: loading fallback */} + + + + + + + ); +}; + +const SubscriptionSettings = () => { + const { data: subscriptionQueryResult } = useQuery({ + query: subscriptionQuery, + }); + const { data: pricesQueryResult } = useQuery({ + query: pricesQuery, + }); + + const subscription = subscriptionQueryResult.currentUser?.subscription; + const plan = subscription?.plan ?? SubscriptionPlan.Free; + const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly; + + const price = pricesQueryResult.prices.find(price => price.plan === plan); + const amount = + plan === SubscriptionPlan.Free + ? '0' + : price + ? recurring === SubscriptionRecurring.Monthly + ? String(price.amount / 100) + : (price.yearlyAmount / 100 / 12).toFixed(2) + : '?'; + + return ( +
+
+
+ + You are current on the{' '} + + {/* TODO: Action */} + {plan} plan + + . +

+ } + /> + +
+

${amount}/month

+
+ {subscription?.status === SubscriptionStatus.Active && ( + <> + + + + {subscription.nextBillAt && ( + + )} + {subscription.canceledAt ? ( + + + + ) : ( + + + + )} + + )} +
+ ); +}; + +const PlanAction = ({ plan }: { plan: string }) => { + const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); + + const gotoPlansSetting = useCallback(() => { + setOpenSettingModalAtom({ + open: true, + activeTab: 'plans', + workspaceId: null, + }); + }, [setOpenSettingModalAtom]); + + return ( + + ); +}; + +const PaymentMethodUpdater = () => { + // TODO: open stripe customer portal + const { isMutating, trigger, data } = useMutation({ + mutation: createCustomerPortalMutation, + }); + + const update = useCallback(() => { + trigger(); + }, [trigger]); + + useEffect(() => { + if (data?.createCustomerPortal) { + window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer'); + } + }, [data]); + + return ( + + ); +}; + +const ResumeSubscription = () => { + const { isMutating, trigger } = useMutation({ + mutation: resumeSubscriptionMutation, + }); + + const resume = useCallback(() => { + trigger(); + }, [trigger]); + + return ( + + ); +}; + +const CancelSubscription = () => { + const { isMutating, trigger } = useMutation({ + mutation: cancelSubscriptionMutation, + }); + + const cancel = useCallback(() => { + trigger(); + }, [trigger]); + + return ( + } + disabled={isMutating} + loading={isMutating} + onClick={cancel /* TODO: popup confirmation modal instead */} + /> + ); +}; + +const BillingHistory = () => { + const { data: invoicesQueryResult } = useQuery({ + query: invoicesQuery, + variables: { + skip: 0, + take: 12, + }, + }); + + const invoices = invoicesQueryResult.currentUser?.invoices ?? []; + + return ( +
+ {invoices.length === 0 ? ( +

There are no invoices to display.

+ ) : ( + // TODO: pagination + invoices.map(invoice => ( + + )) + )} +
+ ); +}; + +const InvoiceLine = ({ + invoice, +}: { + invoice: NonNullable['invoices'][0]; +}) => { + const open = useCallback(() => { + if (invoice.link) { + window.open(invoice.link, '_blank', 'noopener noreferrer'); + } + }, [invoice.link]); + return ( + $, cny => ¥ + desc={`${invoice.status === InvoiceStatus.Paid ? 'Paid' : ''} $${ + invoice.amount / 100 + }`} + > + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts new file mode 100644 index 0000000000..74f87f2c5e --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts @@ -0,0 +1,39 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const subscription = style({}); + +export const billingHistory = style({}); + +export const planCard = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '12px', + border: '1px solid var(--affine-border-color)', + borderRadius: '8px', +}); + +export const currentPlan = style({ + flex: '1 0 0', +}); + +export const planAction = style({ + marginTop: '8px', +}); + +export const planPrice = style({ + fontSize: 'var(--affine-font-h-6)', + fontWeight: 600, +}); + +export const paymentMethod = style({ + marginTop: '24px', +}); + +globalStyle('.dangerous-setting .name', { + color: 'var(--affine-error-color)', +}); + +export const noInvoice = style({ + color: 'var(--affine-text-secondary-color)', + fontSize: 'var(--affine-font-xs)', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx index 9a1c329fd4..97012598b5 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx @@ -7,8 +7,10 @@ import { } from '@blocksuite/icons'; import type { ReactElement, SVGProps } from 'react'; +import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; import { AboutAffine } from './about'; import { AppearanceSettings } from './appearance'; +import { BillingSettings } from './billing'; import { AFFiNECloudPlans } from './plans'; import { Plugins } from './plugins'; import { Shortcuts } from './shortcuts'; @@ -32,8 +34,9 @@ export type GeneralSettingList = GeneralSettingListItem[]; export const useGeneralSettingList = (): GeneralSettingList => { const t = useAFFiNEI18N(); + const status = useCurrentLoginStatus(); - return [ + const settings: GeneralSettingListItem[] = [ { key: 'appearance', title: t['com.affine.settings.appearance'](), @@ -54,14 +57,7 @@ export const useGeneralSettingList = (): GeneralSettingList => { icon: KeyboardIcon, testId: 'plans-panel-trigger', }, - { - key: 'billing', - // TODO: i18n - title: 'Billing', - // TODO: icon - icon: KeyboardIcon, - testId: 'billing-panel-trigger', - }, + { key: 'plugins', title: 'Plugins', @@ -75,6 +71,19 @@ export const useGeneralSettingList = (): GeneralSettingList => { testId: 'about-panel-trigger', }, ]; + + if (status === 'authenticated') { + settings.splice(3, 0, { + key: 'billing', + // TODO: i18n + title: 'Billing', + // TODO: icon + icon: KeyboardIcon, + testId: 'billing-panel-trigger', + }); + } + + return settings; }; interface GeneralSettingProps { @@ -93,6 +102,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => { return ; case 'plans': return ; + case 'billing': + return ; default: return null; } diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx index 1c79de89f2..cf80164f97 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -129,12 +129,11 @@ const Settings = () => { const subscription = data.currentUser?.subscription; const [recurring, setRecurring] = useState( - subscription?.recurring ?? SubscriptionRecurring.Monthly + subscription?.recurring ?? SubscriptionRecurring.Yearly ); const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; - const currentRecurring = - subscription?.recurring ?? SubscriptionRecurring.Monthly; + const currentRecurring = subscription?.recurring; const refresh = useCallback(() => { mutate(); @@ -178,8 +177,6 @@ const Settings = () => { {/* TODO: may scroll current plan into view when first loading? */}
{Array.from(planDetail.values()).map(detail => { - const isCurrent = - currentPlan === detail.plan && currentRecurring === recurring; return (
{

{detail.plan}{' '} - {'discount' in detail && ( - - {detail.discount}% off - - )} + {'discount' in detail && + recurring === SubscriptionRecurring.Yearly && ( + + {detail.discount}% off + + )}

@@ -224,18 +222,26 @@ const Settings = () => { detail.type === 'dynamic' ? ( ) : loggedIn ? ( - isCurrent ? ( + detail.plan === currentPlan && + (currentRecurring === recurring || + (!currentRecurring && + detail.plan === SubscriptionPlan.Free)) ? ( ) : detail.plan === SubscriptionPlan.Free ? ( - ) : currentRecurring !== recurring ? ( + ) : currentRecurring !== recurring && + currentPlan === detail.plan ? ( ) : ( - + ) ) : ( diff --git a/packages/frontend/graphql/src/graphql/create-customer-portal.gql b/packages/frontend/graphql/src/graphql/create-customer-portal.gql new file mode 100644 index 0000000000..d6e268172c --- /dev/null +++ b/packages/frontend/graphql/src/graphql/create-customer-portal.gql @@ -0,0 +1,3 @@ +mutation createCustomerPortal { + createCustomerPortal +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 29330c8fed..5817251585 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -138,6 +138,17 @@ mutation checkout($recurring: SubscriptionRecurring!) { }`, }; +export const createCustomerPortalMutation = { + id: 'createCustomerPortalMutation' as const, + operationName: 'createCustomerPortal', + definitionName: 'createCustomerPortal', + containsFile: false, + query: ` +mutation createCustomerPortal { + createCustomerPortal +}`, +}; + export const createWorkspaceMutation = { id: 'createWorkspaceMutation' as const, operationName: 'createWorkspace', @@ -365,6 +376,7 @@ query invoices($take: Int!, $skip: Int!) { amount reason lastPaymentError + link createdAt } } @@ -416,6 +428,23 @@ mutation removeAvatar { }`, }; +export const resumeSubscriptionMutation = { + id: 'resumeSubscriptionMutation' as const, + operationName: 'resumeSubscription', + definitionName: 'resumeSubscription', + containsFile: false, + query: ` +mutation resumeSubscription { + resumeSubscription { + id + status + nextBillAt + start + end + } +}`, +}; + export const revokeMemberPermissionMutation = { id: 'revokeMemberPermissionMutation' as const, operationName: 'revokeMemberPermission', diff --git a/packages/frontend/graphql/src/graphql/invoices.gql b/packages/frontend/graphql/src/graphql/invoices.gql index 569b77730f..bd26f0896a 100644 --- a/packages/frontend/graphql/src/graphql/invoices.gql +++ b/packages/frontend/graphql/src/graphql/invoices.gql @@ -9,6 +9,7 @@ query invoices($take: Int!, $skip: Int!) { amount reason lastPaymentError + link createdAt } } diff --git a/packages/frontend/graphql/src/graphql/resume-subscription.gql b/packages/frontend/graphql/src/graphql/resume-subscription.gql new file mode 100644 index 0000000000..b56cb767bf --- /dev/null +++ b/packages/frontend/graphql/src/graphql/resume-subscription.gql @@ -0,0 +1,9 @@ +mutation resumeSubscription { + resumeSubscription { + id + status + nextBillAt + start + end + } +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index e38e9b760c..cea6064754 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -182,6 +182,15 @@ export type CheckoutMutationVariables = Exact<{ export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string }; +export type CreateCustomerPortalMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type CreateCustomerPortalMutation = { + __typename?: 'Mutation'; + createCustomerPortal: string; +}; + export type CreateWorkspaceMutationVariables = Exact<{ init: Scalars['Upload']['input']; }>; @@ -368,6 +377,7 @@ export type InvoicesQuery = { amount: number; reason: string; lastPaymentError: string | null; + link: string | null; createdAt: string; }>; } | null; @@ -405,6 +415,22 @@ export type RemoveAvatarMutation = { removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean }; }; +export type ResumeSubscriptionMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type ResumeSubscriptionMutation = { + __typename?: 'Mutation'; + resumeSubscription: { + __typename?: 'UserSubscription'; + id: string; + status: SubscriptionStatus; + nextBillAt: string | null; + start: string; + end: string; + }; +}; + export type RevokeMemberPermissionMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -712,6 +738,11 @@ export type Mutations = variables: CheckoutMutationVariables; response: CheckoutMutation; } + | { + name: 'createCustomerPortalMutation'; + variables: CreateCustomerPortalMutationVariables; + response: CreateCustomerPortalMutation; + } | { name: 'createWorkspaceMutation'; variables: CreateWorkspaceMutationVariables; @@ -737,6 +768,11 @@ export type Mutations = variables: RemoveAvatarMutationVariables; response: RemoveAvatarMutation; } + | { + name: 'resumeSubscriptionMutation'; + variables: ResumeSubscriptionMutationVariables; + response: ResumeSubscriptionMutation; + } | { name: 'revokeMemberPermissionMutation'; variables: RevokeMemberPermissionMutationVariables;