feat(core): impl billing settings (#4652)

This commit is contained in:
liuyi 2023-10-20 09:42:33 +08:00 committed by forehalo
parent 1d62133f4f
commit 858a1da35f
No known key found for this signature in database
16 changed files with 480 additions and 27 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;

View File

@ -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)

View File

@ -117,6 +117,9 @@ class UserInvoiceType implements Partial<UserInvoice> {
@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);

View File

@ -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

View File

@ -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!

View File

@ -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<SettingRowProps>) => {
return (
<div
className={clsx(settingRow, {
'two-col': spreadCol,
disabled,
})}
className={clsx(
settingRow,
{
'two-col': spreadCol,
disabled,
},
className
)}
style={style}
onClick={onClick}
data-testid={props['data-testid']}

View File

@ -86,7 +86,6 @@ globalStyle(`${settingRow} .desc`, {
color: 'var(--affine-text-secondary-color)',
});
globalStyle(`${settingRow} .right-col`, {
width: '250px',
display: 'flex',
justifyContent: 'flex-end',
paddingLeft: '15px',

View File

@ -0,0 +1,272 @@
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import {
cancelSubscriptionMutation,
createCustomerPortalMutation,
type InvoicesQuery,
invoicesQuery,
InvoiceStatus,
pricesQuery,
resumeSubscriptionMutation,
SubscriptionPlan,
subscriptionQuery,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useEffect } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import * as styles from './style.css';
export const BillingSettings = () => {
const status = useCurrentLoginStatus();
if (status !== 'authenticated') {
return null;
}
return (
<>
<SettingHeader
title="Billing"
subtitle="Manage your billing information and invoices."
/>
{/* TODO: loading fallback */}
<Suspense>
<SettingWrapper title="information">
<SubscriptionSettings />
</SettingWrapper>
</Suspense>
{/* TODO: loading fallback */}
<Suspense>
<SettingWrapper title="Billing history">
<BillingHistory />
</SettingWrapper>
</Suspense>
</>
);
};
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 (
<div className={styles.subscription}>
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name="Current Plan"
desc={
<p>
You are current on the{' '}
<a>
{/* TODO: Action */}
{plan} plan
</a>
.
</p>
}
/>
<PlanAction plan={plan} />
</div>
<p className={styles.planPrice}>${amount}/month</p>
</div>
{subscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name="Payment Method"
desc="Provided by Stripe."
>
<PaymentMethodUpdater />
</SettingRow>
{subscription.nextBillAt && (
<SettingRow
name="Renew Date"
desc={`Next billing date: ${new Date(
subscription.nextBillAt
).toLocaleDateString()}`}
/>
)}
{subscription.canceledAt ? (
<SettingRow
name="Expiration Date"
desc={`Your subscription is valid until ${new Date(
subscription.end
).toLocaleDateString()}`}
>
<ResumeSubscription />
</SettingRow>
) : (
<SettingRow
className="dangerous-setting"
name="Cancel Subscription"
desc={`Subscription cancelled, your pro account will expire on ${new Date(
subscription.end
).toLocaleDateString()}`}
>
<CancelSubscription />
</SettingRow>
)}
</>
)}
</div>
);
};
const PlanAction = ({ plan }: { plan: string }) => {
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const gotoPlansSetting = useCallback(() => {
setOpenSettingModalAtom({
open: true,
activeTab: 'plans',
workspaceId: null,
});
}, [setOpenSettingModalAtom]);
return (
<Button
className={styles.planAction}
type="primary"
onClick={gotoPlansSetting}
>
{plan === SubscriptionPlan.Free ? 'Upgrade' : 'Change Plan'}
</Button>
);
};
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 (
<Button onClick={update} loading={isMutating} disabled={isMutating}>
Update
</Button>
);
};
const ResumeSubscription = () => {
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const resume = useCallback(() => {
trigger();
}, [trigger]);
return (
<Button onClick={resume} loading={isMutating} disabled={isMutating}>
Resume
</Button>
);
};
const CancelSubscription = () => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const cancel = useCallback(() => {
trigger();
}, [trigger]);
return (
<IconButton
icon={<ArrowRightSmallIcon />}
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 (
<div className={styles.billingHistory}>
{invoices.length === 0 ? (
<p className={styles.noInvoice}>There are no invoices to display.</p>
) : (
// TODO: pagination
invoices.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const open = useCallback(() => {
if (invoice.link) {
window.open(invoice.link, '_blank', 'noopener noreferrer');
}
}, [invoice.link]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
// TODO: currency to format: usd => $, cny => ¥
desc={`${invoice.status === InvoiceStatus.Paid ? 'Paid' : ''} $${
invoice.amount / 100
}`}
>
<Button onClick={open}>View Invoice</Button>
</SettingRow>
);
};

View File

@ -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)',
});

View File

@ -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 <AboutAffine />;
case 'plans':
return <AFFiNECloudPlans />;
case 'billing':
return <BillingSettings />;
default:
return null;
}

View File

@ -129,12 +129,11 @@ const Settings = () => {
const subscription = data.currentUser?.subscription;
const [recurring, setRecurring] = useState<string>(
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? */}
<div className={styles.planCardsWrapper}>
{Array.from(planDetail.values()).map(detail => {
const isCurrent =
currentPlan === detail.plan && currentRecurring === recurring;
return (
<div
key={detail.plan}
@ -192,11 +189,12 @@ const Settings = () => {
<div className={styles.planTitle}>
<p>
{detail.plan}{' '}
{'discount' in detail && (
<span className={styles.discountLabel}>
{detail.discount}% off
</span>
)}
{'discount' in detail &&
recurring === SubscriptionRecurring.Yearly && (
<span className={styles.discountLabel}>
{detail.discount}% off
</span>
)}
</p>
<p>
<span className={styles.planPrice}>
@ -224,18 +222,26 @@ const Settings = () => {
detail.type === 'dynamic' ? (
<ContactSales />
) : loggedIn ? (
isCurrent ? (
detail.plan === currentPlan &&
(currentRecurring === recurring ||
(!currentRecurring &&
detail.plan === SubscriptionPlan.Free)) ? (
<CurrentPlan />
) : detail.plan === SubscriptionPlan.Free ? (
<Downgrade onActionDone={refresh} />
) : currentRecurring !== recurring ? (
) : currentRecurring !== recurring &&
currentPlan === detail.plan ? (
<ChangeRecurring
// @ts-expect-error must exist
from={currentRecurring}
to={recurring as SubscriptionRecurring}
onActionDone={refresh}
/>
) : (
<Upgrade recurring={recurring} onActionDone={refresh} />
<Upgrade
recurring={recurring as SubscriptionRecurring}
onActionDone={refresh}
/>
)
) : (
<SignupAction>

View File

@ -0,0 +1,3 @@
mutation createCustomerPortal {
createCustomerPortal
}

View File

@ -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',

View File

@ -9,6 +9,7 @@ query invoices($take: Int!, $skip: Int!) {
amount
reason
lastPaymentError
link
createdAt
}
}

View File

@ -0,0 +1,9 @@
mutation resumeSubscription {
resumeSubscription {
id
status
nextBillAt
start
end
}
}

View File

@ -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;